690 lines
32 KiB
Dart
690 lines
32 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import 'package:path/path.dart' as p;
|
||
|
||
import '../core/cron_window.dart';
|
||
import '../core/file_ops.dart';
|
||
import '../core/sync_preview.dart' show isRsyncDeleteOption, isSourceRemovingOption;
|
||
import '../core/sync_tools.dart';
|
||
import '../core/vfs.dart';
|
||
import '../l10n/tr.dart';
|
||
import '../platform/windows/rsync_installer.dart';
|
||
import 'app_controller.dart';
|
||
import 'settings.dart';
|
||
import '../core/rsync_filter.dart';
|
||
import '../core/rsync_launch.dart';
|
||
|
||
/// 앱에 들어 있는 rsync (없으면 null). 시작할 때 [findBundledRsync] 로 정한다.
|
||
/// - Windows: 실행 파일 옆 rsync/rsync.exe (tool/fetch_rsync.ps1 로 준비해 빌드에 넣음)
|
||
/// - Android: 네이티브 라이브러리 폴더의 librsync.so (tool/build_rsync_android.sh, APK 의 jniLibs)
|
||
String? bundledRsync;
|
||
|
||
Future<void> findBundledRsync() async {
|
||
String? path;
|
||
if (Platform.isWindows) {
|
||
path = p.join(p.dirname(Platform.resolvedExecutable), 'rsync', 'rsync.exe');
|
||
} else if (Platform.isAndroid) {
|
||
try {
|
||
final dir = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('nativeLibDir');
|
||
if (dir != null) path = p.join(dir, 'librsync.so');
|
||
} catch (_) {}
|
||
}
|
||
bundledRsync = path != null && File(path).existsSync() ? path : null;
|
||
}
|
||
|
||
/// 지금 설정으로 쓸 rsync 실행 파일 (없으면 null).
|
||
/// - 직접 지정: 그 파일
|
||
/// - 기본: 앱에 들어 있는 것, 없으면 (Windows) 내려받아 둔 것
|
||
Future<String?> rsyncExecutable(AppSettings s) async {
|
||
if (s.rsyncSource == 'custom') {
|
||
return s.rsyncPath.isNotEmpty && File(s.rsyncPath).existsSync() ? s.rsyncPath : null;
|
||
}
|
||
final b = bundledRsync;
|
||
if (b != null && File(b).existsSync()) return b;
|
||
if (!Platform.isWindows) return null;
|
||
return RsyncInstaller.installedPath();
|
||
}
|
||
|
||
/// 이 기기에서 쓸 수 있는 방법
|
||
bool copyMethodAvailable(CopyMethod m, AppSettings s) => switch (m) {
|
||
CopyMethod.builtin => true,
|
||
CopyMethod.robocopy => Platform.isWindows,
|
||
CopyMethod.rsync => Platform.isWindows || s.rsyncSource == 'custom' || bundledRsync != null,
|
||
};
|
||
|
||
/// 실시간 동기화 (lsyncd 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다.
|
||
/// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [AppSettings.liveSyncIntervalSec] 마다 살핀다.
|
||
/// 앱이 켜져 있는 동안만 동작하고, 시작할 때 한 번 맞춘다.
|
||
class LiveSync extends ChangeNotifier {
|
||
final AppController c;
|
||
LiveSync(this.c);
|
||
|
||
static LiveSync? instance;
|
||
|
||
final _watch = <String, StreamSubscription<FileSystemEvent>>{};
|
||
final _timers = <String, Timer>{};
|
||
final _pending = <String, Timer>{};
|
||
final _running = <String>{};
|
||
|
||
/// 쌍마다 마지막 결과 (화면 표시용): (시각, 글)
|
||
final status = <String, (DateTime, String)>{};
|
||
|
||
/// 쌍마다 아직 맞추지 않은 것 (원본 기준 상대 경로, 지울 것은 "− " 를 앞에). 바뀌면 자동으로 다시 센다.
|
||
final pending = <String, List<String>>{};
|
||
|
||
String _sig = '';
|
||
|
||
/// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다
|
||
Timer? _clock;
|
||
|
||
/// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [AppSettings.liveSyncOnStart] 를 따른다)
|
||
final paused = <String>{};
|
||
|
||
/// [hold] 면 켜진 쌍을 모두 멈춘 채로 시작 (모니터링 · [resume] 으로 시작)
|
||
void start({bool hold = false}) {
|
||
instance = this;
|
||
if (hold) paused.addAll(c.settings.liveSyncPairs.where((x) => x.enabled).map(keyOf));
|
||
c.addListener(_apply);
|
||
_apply();
|
||
_clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _onClock());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_disposed = true;
|
||
c.removeListener(_apply);
|
||
_clock?.cancel();
|
||
_stopAll();
|
||
if (instance == this) instance = null;
|
||
super.dispose();
|
||
}
|
||
|
||
/// 맞추기 (rsync · robocopy 프로세스) 는 dispose 뒤에도 끝까지 돌 수 있다 - 그때 끝나며 알리지 않게
|
||
/// (앱을 끄거나 화면을 닫는 중에 맞추기가 끝나면 "used after being disposed" 오류가 났다)
|
||
bool _disposed = false;
|
||
|
||
@override
|
||
void notifyListeners() {
|
||
if (!_disposed) super.notifyListeners();
|
||
}
|
||
|
||
/// 지금 동작 시간인지 (일정이 없으면 늘)
|
||
static bool activeNow(LiveSyncPair x, [DateTime? now]) => scheduleActive(x.schedule, now ?? DateTime.now());
|
||
|
||
bool isPaused(LiveSyncPair x) => paused.contains(keyOf(x));
|
||
|
||
/// 지금 지켜보는 쌍 (켜져 있고 멈추지 않은 것)
|
||
List<LiveSyncPair> get watching => [
|
||
for (final x in c.settings.liveSyncPairs)
|
||
if (x.enabled && !paused.contains(keyOf(x))) x,
|
||
];
|
||
|
||
void pause(LiveSyncPair x) => _setPaused({...paused, keyOf(x)});
|
||
void resume(LiveSyncPair x) => _setPaused({...paused}..remove(keyOf(x)));
|
||
void pauseAll() => _setPaused({...paused, ...c.settings.liveSyncPairs.map(keyOf)});
|
||
void resumeAll() => _setPaused({});
|
||
|
||
/// 시작할 때 고른 쌍만 (나머지는 멈춤)
|
||
void runOnly(Iterable<LiveSyncPair> xs) {
|
||
final keep = xs.map(keyOf).toSet();
|
||
_setPaused({for (final x in c.settings.liveSyncPairs) if (!keep.contains(keyOf(x))) keyOf(x)});
|
||
}
|
||
|
||
void _setPaused(Set<String> v) {
|
||
paused
|
||
..clear()
|
||
..addAll(v);
|
||
_apply();
|
||
notifyListeners();
|
||
}
|
||
|
||
void _onClock() {
|
||
for (final x in watching) {
|
||
if (activeNow(x) && (pending[keyOf(x)]?.isNotEmpty ?? false)) _schedule(x);
|
||
}
|
||
notifyListeners(); // 남은 시간 표시
|
||
}
|
||
|
||
/// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로.
|
||
/// 관리자 지시 (10/19): rsync 쌍이 원격이라 앱이 맞출 때 (그리고 그 대기 목록) 도 환경 설정 rsync 옵션의 거르기를 따른다.
|
||
/// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이
|
||
RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none;
|
||
|
||
/// 지울 것에 영향을 주는 옵션 (rsync 쌍만 - 거르기 · --delete-excluded). 확인할 때 쌍에 기억한다 ([LiveSyncPair.confirmedOptions])
|
||
String _optionsOf(LiveSyncPair x) => x.method == 'rsync' ? c.settings.rsyncOptions : '';
|
||
|
||
/// 관리자 3a 판단 (10/20): 확인한 쌍이라도 옵션이 바뀌어 지울 것이 늘었으면 다시 묻는다 (로컬 · 원격 쌍 모두).
|
||
/// 늘지 않았으면 (줄거나 같음) 묻지 않고 지금 옵션을 기억한다. 예전에 확인한 쌍 (기억한 옵션 없음) 은 예전 동작
|
||
/// (--delete-excluded 를 따르지 않음) 과 견준다 - 업데이트 뒤 --delete-excluded 로 새로 지워질 것이 있으면 묻는다
|
||
Future<bool> _deletesGrew(LiveSyncPair x) async {
|
||
final old = x.confirmedOptions;
|
||
final now = _filterOf(x);
|
||
final before = old == null
|
||
? RsyncFilter(now.rules, unsupported: now.unsupported)
|
||
: x.method == 'rsync'
|
||
? RsyncFilter.parse(old)
|
||
: RsyncFilter.none;
|
||
List<String> gone(List<String> d) => [for (final s in d) if (s.startsWith('− ')) s.substring(2)];
|
||
final a = gone(await diff(x, limit: 100000, filter: now));
|
||
if (a.isEmpty) return false;
|
||
final b = gone(await diff(x, limit: 100000, filter: before));
|
||
return a.any((d) => !b.any((e) => d == e || d.startsWith('$e/')));
|
||
}
|
||
|
||
/// 확인한 뒤 옵션이 바뀌어 다시 묻는 쌍 (확인 창에 그 이유를 보인다)
|
||
final reasked = <String>{};
|
||
|
||
/// P0 (관리자 3a 10/20, 28 재현): 지우기 포함 rsync 쌍의 옵션에 미리 볼 수 없는 거르기 규칙 (-,s · H · P · merge · --files-from 등)
|
||
/// 이 있으면 실제 rsync 가 지울 목록에 없던 것까지 지울 수 있다 ("-,s a.txt" · "H a.txt" 는 받는 쪽 a.txt 를 지움).
|
||
/// 저절로 도는 맞추기는 지우지 않고 보내기만 하고, 지우기는 카드의 [지금 지우기 (확인)] 로 한 번씩만. 값은 그 규칙들
|
||
final noAutoDelete = <String, List<String>>{};
|
||
final _deleteOnce = <String>{};
|
||
|
||
/// 이 쌍의 지우기에 걸리는 미리 볼 수 없는 거르기 규칙 (지우기 포함 rsync 쌍만)
|
||
List<String> unsupportedOf(LiveSyncPair x) => x.delete ? _filterOf(x).unsupported : const [];
|
||
|
||
/// 미리 볼 수 없는 규칙이 있는 쌍의 "맞출 것" 은 지우기 없이 센다 (저절로 지우지 않으므로 − 줄이 늘 남지 않게)
|
||
LiveSyncPair _pendingPair(LiveSyncPair x) => unsupportedOf(x).isNotEmpty ? x.copyWith(delete: false) : x;
|
||
|
||
/// [지금 지우기] 창에 보일 지울 것 (미리 본 것 - 위 규칙 때문에 실제와 다를 수 있음) · 그 가운데 --delete-excluded 로 지워지는 것
|
||
Future<(List<String>, List<String>)> previewDeletes(LiveSyncPair x) async {
|
||
final excl = <String>[];
|
||
final del = [
|
||
for (final d in await diff(x.copyWith(delete: true), limit: 100000, filter: _filterOf(x), excluded: excl))
|
||
if (d.startsWith('− ')) d.substring(2)
|
||
];
|
||
return (del, excl);
|
||
}
|
||
|
||
/// 확인 창 (FilterLimitNote 와 체크) 을 거친 뒤 이번 한 번만 지우기 포함으로 맞춘다. 다음 자동 맞추기는 다시 보내기만
|
||
Future<void> deleteNow(LiveSyncPair x) async {
|
||
_deleteOnce.add(keyOf(x));
|
||
await syncNow(x);
|
||
}
|
||
|
||
/// 받는 쪽에만 있는 [path] (상대 경로 [r]) 가 지워지는 모양을 [out] 에 "− 경로" 로 (rsync · _mirrorV 와 같게):
|
||
/// 거른 것은 지우지 않고 (--delete-excluded 면 지움), 안에 거른 것이 든 폴더는 남기고 그 안의 거르지 않은 것만.
|
||
/// 10/20 (15, 28 의 장면으로 찾음): 예전에는 그런 폴더도 "− 폴더" 한 줄이라 지울지 묻는 창에 폴더가 통째로 보이고,
|
||
/// 맞춘 뒤에도 (폴더가 남으므로) 맞출 것에 계속 남았다.
|
||
/// [excluded] 가 있으면 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 거기에도 (확인 창에서 따로 묶음)
|
||
static Future<void> _gone(String path, String r, bool isDir, RsyncFilter filter, List<String> out, int limit,
|
||
[List<String>? excluded]) async {
|
||
if (out.length >= limit) return;
|
||
// 관리자 지시 (10/20): 받는 쪽 규칙 (- · P · R · ,r - --delete-excluded 면 쪽을 적지 않은 규칙은 빠짐) 에 걸린 것만 지킨다
|
||
if (filter.protects(r, isDir: isDir)) return;
|
||
Future<void> mark() async {
|
||
// --delete-excluded 때문에 지워지는 것: 보내는 쪽에서 거른 것 · 그런 것이 든 폴더
|
||
if (excluded != null &&
|
||
filter.deleteExcluded &&
|
||
(filter.excluded(r, isDir: isDir) || (isDir && await _hasExcluded(path, r, filter, sender: true)))) {
|
||
excluded.add(r);
|
||
}
|
||
}
|
||
|
||
if (!isDir || !filter.protectsAny || !await _hasExcluded(path, r, filter)) {
|
||
out.add('− $r');
|
||
await mark();
|
||
return;
|
||
}
|
||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||
try {
|
||
items = await vList(path);
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
for (final e in items) {
|
||
final name = vBasename(e.path);
|
||
if (name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||
await _gone(e.path, '$r/$name', e.isDir, filter, out, limit, excluded);
|
||
}
|
||
}
|
||
|
||
/// 폴더 [dir] (상대 경로 [rel]) 안에 받는 쪽에서 지키는 것 ([sender] 면 보내는 쪽에서 거른 것) 이 하나라도 있는지
|
||
static Future<bool> _hasExcluded(String dir, String rel, RsyncFilter filter, {bool sender = false}) async {
|
||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||
try {
|
||
items = await vList(dir);
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
for (final e in items) {
|
||
final r = '$rel/${vBasename(e.path)}';
|
||
if (sender ? filter.excluded(r, isDir: e.isDir) : filter.protects(r, isDir: e.isDir)) return true;
|
||
if (e.isDir && await _hasExcluded(e.path, r, filter, sender: sender)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// [excluded]: 지울 것 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 을 모은다 ([_gone])
|
||
static Future<List<String>> diff(LiveSyncPair x,
|
||
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
|
||
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit, filter: filter, excluded: excluded);
|
||
final out = <String>[];
|
||
Future<void> walk(String src, String dst, String rel) async {
|
||
if (out.length >= limit) return;
|
||
final names = <String>{};
|
||
List<FileSystemEntity> items;
|
||
try {
|
||
items = await Directory(src).list(followLinks: false).toList();
|
||
} catch (_) {
|
||
return; // 원본을 읽지 못함: 이 폴더의 "지울 것" 은 세지 않는다
|
||
}
|
||
for (final e in items) {
|
||
if (out.length >= limit) return;
|
||
final name = p.basename(e.path);
|
||
if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞출 것이 아니다
|
||
final r = rel.isEmpty ? name : '$rel/$name';
|
||
// 10/20 (15, 28 의 장면으로 찾음): rsync 쌍은 옵션의 거르기를 따른다 - 예전에는 rsync 가 보내지 않는 것을
|
||
// 맞출 것으로 늘 세고, 지우지 않는 것 (받는 쪽의 거른 것) 을 지울지 물었다
|
||
if (filter.excluded(r, isDir: e is Directory)) continue;
|
||
names.add(name);
|
||
final t = p.join(dst, name);
|
||
if (e is Directory) {
|
||
await walk(e.path, t, r);
|
||
} else if (e is File) {
|
||
final st = await e.stat();
|
||
final tf = File(t);
|
||
if (!await tf.exists()) {
|
||
out.add(r);
|
||
continue;
|
||
}
|
||
final ts = await tf.stat();
|
||
if (ts.size != st.size || ts.modified.difference(st.modified).inSeconds.abs() > 2) out.add(r);
|
||
}
|
||
}
|
||
if (x.delete && await Directory(dst).exists()) {
|
||
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
|
||
final name = p.basename(e.path);
|
||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||
await _gone(e.path, rel.isEmpty ? name : '$rel/$name', e is Directory, filter, out, limit, excluded);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (await Directory(x.source).exists()) await walk(x.source, x.target, '');
|
||
return out;
|
||
}
|
||
|
||
/// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면
|
||
static Future<List<String>> _diffV(LiveSyncPair x,
|
||
{int limit = 500, RsyncFilter filter = RsyncFilter.none, List<String>? excluded}) async {
|
||
final out = <String>[];
|
||
Future<void> walk(String src, String dst, String rel) async {
|
||
if (out.length >= limit) return;
|
||
final there = <String, ({String path, bool isDir, int size, DateTime modified})>{};
|
||
try {
|
||
for (final e in await vList(dst)) {
|
||
there[vBasename(e.path)] = e;
|
||
}
|
||
} catch (_) {} // 대상 폴더가 아직 없음
|
||
final names = <String>{};
|
||
List<({String path, bool isDir, int size, DateTime modified})> items;
|
||
try {
|
||
items = await vList(src, strict: true);
|
||
} catch (_) {
|
||
return; // 원본을 읽지 못함: "지울 것" 을 세지 않는다
|
||
}
|
||
for (final e in items) {
|
||
if (out.length >= limit) return;
|
||
final name = vBasename(e.path);
|
||
if (isAppTempName(name)) continue; // 78 보충
|
||
final r = rel.isEmpty ? name : '$rel/$name';
|
||
if (filter.excluded(r, isDir: e.isDir)) continue; // 관리자 지시 (10/19): 맞추기 (_mirrorV) 와 같은 거르기
|
||
names.add(name);
|
||
if (e.isDir) {
|
||
await walk(e.path, vJoin(dst, name), r);
|
||
continue;
|
||
}
|
||
final t = there[name];
|
||
// M1: 받는 쪽이 같은 이름의 폴더면 지우기 포함일 때만 바뀐다 (아니면 건너뜀 - 기다리는 것으로 세지 않음)
|
||
if (t != null && t.isDir && !x.delete) continue;
|
||
if (t == null || t.isDir || t.size != e.size || t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2)))) {
|
||
out.add(r);
|
||
}
|
||
}
|
||
if (x.delete) {
|
||
for (final t in there.entries) {
|
||
final name = t.key;
|
||
if (names.contains(name) || name.endsWith('.jjsync') || isAppTempName(name)) continue;
|
||
await _gone(t.value.path, rel.isEmpty ? name : '$rel/$name', t.value.isDir, filter, out, limit, excluded);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (await vExists(x.source)) await walk(x.source, x.target, '');
|
||
return out;
|
||
}
|
||
|
||
Future<void> refreshPending(LiveSyncPair x) async {
|
||
final k = keyOf(x);
|
||
// 68: 원본을 읽을 수 없으면 "맞출 것 없음" 이 아니라 문제로 알린다
|
||
try {
|
||
if (!await vExists(x.source)) throw const FileSystemException('없음');
|
||
await vList(x.source, strict: true);
|
||
if (problems[k]?.empty == false) problems.remove(k); // 다시 읽힌다
|
||
} catch (e) {
|
||
problems[k] = SourceUnreadableException(x.source, cause: e);
|
||
}
|
||
try {
|
||
pending[k] = await diff(_pendingPair(x), filter: _filterOf(x));
|
||
await _countOnlyTarget(_pendingPair(x));
|
||
} catch (_) {}
|
||
notifyListeners();
|
||
}
|
||
|
||
/// 244: 지우기 포함이 아닌 쌍에서 대상에만 있는 항목 수 (맞출 것이 없을 때 "같습니다" 대신 사실대로 보이려고)
|
||
final onlyTarget = <String, int>{};
|
||
|
||
Future<void> _countOnlyTarget(LiveSyncPair x) async {
|
||
final k = keyOf(x);
|
||
if (x.delete || (pending[k]?.isNotEmpty ?? true)) {
|
||
onlyTarget.remove(k);
|
||
return;
|
||
}
|
||
// 지우기 포함으로 셌을 때 "지울 것" (− ) 이 곧 대상에만 있는 것
|
||
try {
|
||
onlyTarget[k] = [for (final d in await diff(x.copyWith(delete: true), limit: 10000, filter: _filterOf(x))) if (d.startsWith('− ')) d].length;
|
||
} catch (_) {
|
||
onlyTarget.remove(k); // 세지 못하면 예전 글
|
||
}
|
||
}
|
||
|
||
static String keyOf(LiveSyncPair x) => '${x.source}=>${x.target}';
|
||
|
||
/// 지우기를 확인하지 않은 쌍: 대상에만 있어 지워질 항목 (원본 기준 상대 경로). 확인 창에 보여 준다 (42)
|
||
final toDelete = <String, List<String>>{};
|
||
|
||
/// [toDelete] 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더). 확인 창에서 따로 묶어 보인다
|
||
/// (관리자 3a 조건 10/20: 사용자가 알고 누르게)
|
||
final toDeleteExcluded = <String, List<String>>{};
|
||
|
||
/// 원본을 읽지 못했거나 원본이 비어 멈춘 쌍 (68 · 70). 성공하면 지운다. 카드에 빨갛게 · 작업 알림에도
|
||
final problems = <String, SourceUnreadableException>{};
|
||
|
||
/// 원본이 비어 멈춘 쌍에서 지워질 항목 (70: 목록을 보고 [그래도 맞추기])
|
||
final emptyDeletes = <String, List<String>>{};
|
||
|
||
/// 다음 한 번은 원본이 비어도 맞춘다 (70)
|
||
final _allowEmpty = <String>{};
|
||
|
||
/// 70: 원본이 정말 비어 있는 것을 확인했다 → 이번 한 번 그대로 맞춘다 (대상에서 지움)
|
||
Future<void> syncEmptyAnyway(LiveSyncPair x) async {
|
||
_allowEmpty.add(keyOf(x));
|
||
await syncNow(x);
|
||
}
|
||
|
||
/// 설정의 쌍을 바꾼다 (같은 원본 → 대상)
|
||
Future<void> _setPair(LiveSyncPair old, LiveSyncPair now) => c.updateSettings((s) => s.liveSyncPairs = [
|
||
for (final p in s.liveSyncPairs) keyOf(p) == keyOf(old) ? now : p,
|
||
]);
|
||
|
||
/// 지울 목록을 보고 결정: [delete] true = 지우기 포함으로 맞추기 (확인함), false = 지우기 끄기. 그 뒤 바로 맞춘다
|
||
Future<void> decideDelete(LiveSyncPair x, {required bool delete}) async {
|
||
final now = delete ? x.copyWith(deleteConfirmed: true, confirmedOptions: _optionsOf(x)) : x.copyWith(delete: false);
|
||
toDelete.remove(keyOf(x));
|
||
toDeleteExcluded.remove(keyOf(x));
|
||
reasked.remove(keyOf(x));
|
||
await _setPair(x, now);
|
||
await syncNow(now);
|
||
}
|
||
|
||
void _stopAll() {
|
||
for (final s in _watch.values) {
|
||
s.cancel();
|
||
}
|
||
for (final t in [..._timers.values, ..._pending.values]) {
|
||
t.cancel();
|
||
}
|
||
_watch.clear();
|
||
_timers.clear();
|
||
_pending.clear();
|
||
}
|
||
|
||
/// 설정이 바뀌면 감시를 다시 꾸린다
|
||
void _apply() {
|
||
final s = c.settings;
|
||
final pairs = watching;
|
||
final sig = jsonEncode([for (final x in pairs) x.toJson(), s.liveSyncIntervalSec]);
|
||
if (sig == _sig) return;
|
||
_sig = sig;
|
||
_stopAll();
|
||
// 258: 지우거나 끈 · 멈춘 쌍의 "원본을 읽지 못해 멈춤" 은 지운다 (작업 알림 "⚠ 동기화 멈춤 N개" 가 앱을 다시 켤 때까지 남았다)
|
||
final keys = pairs.map(keyOf).toSet();
|
||
problems.removeWhere((k, _) => !keys.contains(k));
|
||
for (final x in pairs) {
|
||
final k = keyOf(x);
|
||
if (!isDav(x.source) && !Directory(x.source).existsSync()) {
|
||
status[k] = (DateTime.now(), tr('원본 폴더가 없습니다'));
|
||
continue;
|
||
}
|
||
// 대상이 원본 안이면 맞출 때마다 원본이 바뀌어 끝없이 돈다. 34: 원본이 대상 안 (안쪽 → 바깥) 은 지우기가 없을 때만
|
||
if (nestingProblem(x, allowInnerToOuter: s.allowInnerToOuter) case final nest?) {
|
||
status[k] = (DateTime.now(), nest);
|
||
continue;
|
||
}
|
||
if (Platform.isWindows && !isDav(x.source)) {
|
||
try {
|
||
// 78 보충: 이름 바꾸는 중의 임시 이름 (~jjren_ · ~jjback_) 은 바뀐 것으로 치지 않는다
|
||
_watch[k] = Directory(x.source).watch(recursive: true).listen((ev) {
|
||
if (isAppTempName(p.basename(ev.path))) return;
|
||
_schedule(x);
|
||
});
|
||
} catch (_) {
|
||
_timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x));
|
||
}
|
||
} else {
|
||
// Android 등 · 원본이 WebDAV: 폴더 안쪽까지 감시가 안 되므로 정해진 간격으로 살핀다
|
||
_timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x));
|
||
}
|
||
_schedule(x); // 시작할 때 한 번
|
||
}
|
||
notifyListeners();
|
||
}
|
||
|
||
/// 바뀜 · 정해진 간격: 다른 점을 다시 세고, 동작 시간이면 3초 뒤 맞춘다 (모아서)
|
||
void _schedule(LiveSyncPair x) {
|
||
final k = keyOf(x);
|
||
_pending[k]?.cancel();
|
||
_pending[k] = Timer(const Duration(seconds: 3), () async {
|
||
await refreshPending(x);
|
||
if (activeNow(x) && (pending[k]?.isNotEmpty ?? false)) await syncNow(x);
|
||
});
|
||
}
|
||
|
||
bool isRunning(LiveSyncPair x) => _running.contains(keyOf(x));
|
||
|
||
/// 지금 맞추는 쌍이 있는지
|
||
bool get anyRunning => _running.isNotEmpty;
|
||
|
||
/// 지금 맞추기 (원본 내용 → 대상)
|
||
/// 맞추면 안 되는 쌍이면 그 이유 (34: 대상이 원본 안 = 끝없이 돎, 원본이 대상 안 + 지우기 = 대상의 다른 파일이 지워짐)
|
||
static String? nestingProblem(LiveSyncPair x, {bool allowInnerToOuter = true}) {
|
||
if (isSameOrInside(x.target, x.source)) return tr('대상이 원본과 같거나 원본 안에 있습니다');
|
||
if (x.delete && isSameOrInside(x.source, x.target)) return tr('원본이 대상 안에 있으면 지우기 포함으로 맞출 수 없습니다');
|
||
// 102: 환경 설정에서 안쪽 → 바깥을 끄면 늘 막는다
|
||
if (!allowInnerToOuter && isSameOrInside(x.source, x.target)) return tr('원본이 대상 안에 있습니다 (환경 설정에서 막아 둠)');
|
||
return null;
|
||
}
|
||
|
||
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 rsync 옵션 (있으면 맞추지 않는다 - 카드에 빨갛게)
|
||
List<String> unreadableOf(LiveSyncPair x) => unreadableOptions(_optionsOf(x));
|
||
|
||
Future<void> syncNow(LiveSyncPair x) async {
|
||
final k = keyOf(x);
|
||
final bad = unreadableOf(x);
|
||
final nest = bad.isNotEmpty
|
||
? trf('옵션을 알아볼 수 없습니다: {0}', [bad.join(' · ')])
|
||
: nestingProblem(x, allowInnerToOuter: c.settings.allowInnerToOuter);
|
||
if (nest != null) {
|
||
status[k] = (DateTime.now(), nest);
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
if (_running.contains(k)) {
|
||
_schedule(x); // 도는 중에 바뀐 것은 끝난 뒤 다시
|
||
return;
|
||
}
|
||
_running.add(k);
|
||
notifyListeners();
|
||
final s = c.settings;
|
||
final asked = x;
|
||
try {
|
||
// 42: 지우기를 아직 확인하지 않았으면, 지울 것이 있을 때 지우지 않고 맞추고 확인을 기다린다 (목록은 [toDelete])
|
||
final once = _deleteOnce.remove(k);
|
||
final limits = unsupportedOf(x);
|
||
if (limits.isEmpty) {
|
||
noAutoDelete.remove(k);
|
||
} else {
|
||
noAutoDelete[k] = limits;
|
||
}
|
||
// 미리 볼 수 없는 규칙이 있으면 묻지도 않는다 (묻는 목록이 실제와 달라 - 28: 묻는 목록 밖의 a.txt 가 지워짐 · P 는 지킨 것을 끝없이 물음)
|
||
var ask = limits.isEmpty && x.delete && !x.deleteConfirmed;
|
||
if (limits.isEmpty && x.delete && x.deleteConfirmed && x.confirmedOptions != _optionsOf(x)) {
|
||
if (await _deletesGrew(x)) {
|
||
ask = true;
|
||
reasked.add(k);
|
||
} else {
|
||
final now = x.copyWith(confirmedOptions: _optionsOf(x));
|
||
await _setPair(x, now);
|
||
x = now;
|
||
}
|
||
}
|
||
if (ask) {
|
||
final excl = <String>[];
|
||
final del = [
|
||
for (final d in await diff(x, limit: 100000, filter: _filterOf(x), excluded: excl)) if (d.startsWith('− ')) d.substring(2)
|
||
];
|
||
if (del.isEmpty) {
|
||
final now = x.copyWith(deleteConfirmed: true, confirmedOptions: _optionsOf(x));
|
||
await _setPair(x, now);
|
||
x = now;
|
||
reasked.remove(k);
|
||
} else {
|
||
toDelete[k] = del;
|
||
toDeleteExcluded[k] = excl;
|
||
x = x.copyWith(delete: false);
|
||
}
|
||
} else {
|
||
toDelete.remove(k);
|
||
toDeleteExcluded.remove(k);
|
||
reasked.remove(k);
|
||
}
|
||
if (limits.isNotEmpty && !once) x = x.copyWith(delete: false); // 보내기만
|
||
// 지우기 포함이면 원본을 확실히 읽을 수 있을 때만 (rsync --delete · robocopy /PURGE 도). 못 읽으면 멈추고 알린다
|
||
final allowEmpty = _allowEmpty.remove(k);
|
||
if (x.delete) await ensureSourceForDelete(x.source, x.target, allowEmpty: allowEmpty);
|
||
// 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 맞춘다 (크기 · 시각 비교)
|
||
final method = isDav(x.source) || isDav(x.target) ? CopyMethod.builtin : CopyMethod.of(x.method);
|
||
// M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다
|
||
final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps);
|
||
final n = switch (method) {
|
||
CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty, filter: _filterOf(x)),
|
||
CopyMethod.rsync => await _rsync(x, s),
|
||
CopyMethod.robocopy => await _robocopy(x, s),
|
||
};
|
||
status[k] = toDelete[k] != null
|
||
? (DateTime.now(), trf('지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요', [toDelete[k]!.length]))
|
||
: ops.dirConflicts.isNotEmpty
|
||
? (DateTime.now(), trf('{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개', [n < 0 ? 0 : n, ops.dirConflicts.length]))
|
||
: (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
|
||
problems.remove(k);
|
||
emptyDeletes.remove(k);
|
||
pending[k] = await diff(_pendingPair(asked), filter: _filterOf(asked));
|
||
await _countOnlyTarget(_pendingPair(asked));
|
||
if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target]));
|
||
} on SourceUnreadableException catch (e) {
|
||
// 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에
|
||
problems[k] = e;
|
||
status[k] = (
|
||
DateTime.now(),
|
||
e.locked
|
||
? tr('마스터 비밀번호가 필요해 기다리는 중')
|
||
: e.empty
|
||
? tr('원본 폴더가 비어 있어 멈춤')
|
||
: tr('원본을 읽을 수 없어 멈춤')
|
||
);
|
||
if (e.empty) {
|
||
try {
|
||
emptyDeletes[k] = [for (final d in await diff(asked, limit: 100000, filter: _filterOf(asked))) if (d.startsWith('− ')) d.substring(2)];
|
||
} catch (_) {}
|
||
} else {
|
||
emptyDeletes.remove(k);
|
||
}
|
||
c.note(trf('실시간 동기화 멈춤: {0} → {1}: {2}', [x.source, x.target, e]));
|
||
} catch (e) {
|
||
status[k] = (DateTime.now(), trf('실패: {0}', [e]));
|
||
c.note(trf('실시간 동기화 실패: {0} → {1}: {2}', [x.source, x.target, e]));
|
||
} finally {
|
||
_running.remove(k);
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// rsync: 원본 "내용" 을 대상으로 (끝에 / ), 지우기는 --delete. 보낸 파일 수 (모르면 -1)
|
||
Future<int> _rsync(LiveSyncPair x, AppSettings s) async {
|
||
final exe = await rsyncExecutable(s);
|
||
if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)'));
|
||
final args = rsyncArgsFor(x, s, windows: Platform.isWindows);
|
||
await Directory(x.target).create(recursive: true);
|
||
// P0 (10/20): Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 (' · * 를 런타임이 다시 읽지 않게 - [prepareRsyncLaunch])
|
||
final launch = await prepareRsyncLaunch(args);
|
||
final ProcessResult r;
|
||
try {
|
||
r = await Process.run(exe, launch.args,
|
||
workingDirectory: launch.workingDirectory, environment: launch.environment, stdoutEncoding: utf8, stderrEncoding: utf8);
|
||
} finally {
|
||
launch.dispose();
|
||
}
|
||
if (!rsyncOk(r.exitCode)) throw ProcessException(exe, const [], '${r.stderr}'.trim(), r.exitCode);
|
||
return RsyncOutput().feed('${r.stdout}\n').length;
|
||
}
|
||
|
||
/// rsync 인수 (실시간 동기화). 지우기는 쌍의 "지우기 포함" 으로만 - 설정의 rsync 옵션에 --delete · --del 이 있어도
|
||
/// 41 · 42 의 확인을 건너뛰지 않게 뺀다 (97)
|
||
static List<String> rsyncArgsFor(LiveSyncPair x, AppSettings s, {required bool windows}) => [
|
||
// 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다
|
||
...splitOptions(s.rsyncOptions)
|
||
.where((o) => o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)),
|
||
if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
|
||
if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}',
|
||
if (x.delete) '--delete',
|
||
// 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창
|
||
// (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름)
|
||
if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded',
|
||
...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다
|
||
'${toCygwinPath(x.source, windows: windows)}/',
|
||
'${toCygwinPath(x.target, windows: windows)}/',
|
||
];
|
||
|
||
/// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97
|
||
static List<String> robocopyArgsFor(LiveSyncPair x, AppSettings s) {
|
||
final opts = [
|
||
for (final o in splitOptions(s.robocopyOptions))
|
||
if (o.toUpperCase() == '/MIR') '/E' else if (o.toUpperCase() != '/PURGE' && !isSourceRemovingOption(o)) o,
|
||
];
|
||
return [
|
||
x.source,
|
||
x.target,
|
||
...opts,
|
||
if (!opts.any((o) => o.toUpperCase() == '/E')) '/E',
|
||
// 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동과 같게 - robocopyExtraOptions)
|
||
if (!robocopyLinkOption(opts)) '/XJ',
|
||
if (x.delete) '/PURGE',
|
||
...appTempRobocopyExcludes, // 78 보충
|
||
if (s.copyBandwidthKBps > 0) '/IPG:${robocopyIpg(s.copyBandwidthKBps)}',
|
||
'/BYTES', '/NJH', '/NJS', '/NDL', '/NP',
|
||
];
|
||
}
|
||
|
||
Future<int> _robocopy(LiveSyncPair x, AppSettings s) async {
|
||
final r = await Process.run('robocopy', robocopyArgsFor(x, s));
|
||
if (!robocopyOk(r.exitCode)) throw ProcessException('robocopy', const [], '${r.stdout}'.trim(), r.exitCode);
|
||
return RobocopyOutput().feed('${r.stdout}\n').length;
|
||
}
|
||
}
|