import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import '../core/cron_window.dart'; import '../core/file_ops.dart'; import '../core/sync_tools.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'app_controller.dart'; import 'settings.dart'; import '../core/rsync_filter.dart'; import '../core/rsync_launch.dart'; import 'sync/sync_commands.dart'; import 'sync/sync_diff.dart'; import 'copy_error_text.dart' show userErrorText; export 'sync/sync_commands.dart' show bundledRsync, findBundledRsync, rsyncExecutable, copyMethodAvailable; /// 실시간 동기화 (lsyncd 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다. /// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [CopySettings.liveSyncIntervalSec] 마다 살핀다. /// 앱이 켜져 있는 동안만 동작하고, 시작할 때 한 번 맞춘다. class LiveSync extends ChangeNotifier { final AppController c; /// 지금 시각 (처음 값 = 실제 시계). 카드의 "마지막: HH:MM" 에 남기는 때 - 캡처 시험이 정한 시각을 준다 final DateTime Function() now; LiveSync(this.c, {DateTime Function()? now}) : now = now ?? DateTime.now; static LiveSync? instance; final _watch = >{}; final _timers = {}; final _pending = {}; final _running = {}; /// 쌍마다 마지막 결과 (화면 표시용): (시각, 글) final status = {}; /// 쌍마다 아직 맞추지 않은 것 (원본 기준 상대 경로, 지울 것은 "− " 를 앞에). 바뀌면 자동으로 다시 센다. final pending = >{}; String _sig = ''; /// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다 Timer? _clock; /// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [CopySettings.liveSyncOnStart] 를 따른다) final paused = {}; /// [hold] 면 켜진 쌍을 모두 멈춘 채로 시작 (모니터링 · [resume] 으로 시작) void start({bool hold = false}) { instance = this; if (hold) paused.addAll(c.settings.copy.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 get watching => [ for (final x in c.settings.copy.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.copy.liveSyncPairs.map(keyOf)}); void resumeAll() => _setPaused({}); /// 시작할 때 고른 쌍만 (나머지는 멈춤) void runOnly(Iterable xs) { final keep = xs.map(keyOf).toSet(); _setPaused({for (final x in c.settings.copy.liveSyncPairs) if (!keep.contains(keyOf(x))) keyOf(x)}); } void _setPaused(Set 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.copy.rsyncOptions) : RsyncFilter.none; /// 지울 것에 영향을 주는 옵션 (rsync 쌍만 - 거르기 · --delete-excluded). 확인할 때 쌍에 기억한다 ([LiveSyncPair.confirmedOptions]) String _optionsOf(LiveSyncPair x) => x.method == 'rsync' ? c.settings.copy.rsyncOptions : ''; /// 관리자 3a 판단 (10/20): 확인한 쌍이라도 옵션이 바뀌어 지울 것이 늘었으면 다시 묻는다 (로컬 · 원격 쌍 모두). /// 늘지 않았으면 (줄거나 같음) 묻지 않고 지금 옵션을 기억한다. 예전에 확인한 쌍 (기억한 옵션 없음) 은 예전 동작 /// (--delete-excluded 를 따르지 않음) 과 견준다 - 업데이트 뒤 --delete-excluded 로 새로 지워질 것이 있으면 묻는다 Future _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 gone(List d) => [for (final s in d) if (s.startsWith('− ')) s.substring(2)]; final a = gone(await SyncDiff.of(x, limit: 100000, filter: now)); if (a.isEmpty) return false; final b = gone(await SyncDiff.of(x, limit: 100000, filter: before)); return a.any((d) => !b.any((e) => d == e || d.startsWith('$e/'))); } /// 확인한 뒤 옵션이 바뀌어 다시 묻는 쌍 (확인 창에 그 이유를 보인다) final reasked = {}; /// P0 (관리자 3a 10/20, 28 재현): 지우기 포함 rsync 쌍의 옵션에 미리 볼 수 없는 거르기 규칙 (-,s · H · P · merge · --files-from 등) /// 이 있으면 실제 rsync 가 지울 목록에 없던 것까지 지울 수 있다 ("-,s a.txt" · "H a.txt" 는 받는 쪽 a.txt 를 지움). /// 저절로 도는 맞추기는 지우지 않고 보내기만 하고, 지우기는 카드의 [지금 지우기 (확인)] 로 한 번씩만. 값은 그 규칙들 final noAutoDelete = >{}; final _deleteOnce = {}; /// 이 쌍의 지우기에 걸리는 미리 볼 수 없는 거르기 규칙 (지우기 포함 rsync 쌍만) List 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, List)> previewDeletes(LiveSyncPair x) async { final excl = []; final del = [ for (final d in await SyncDiff.of(x.copyWith(delete: true), limit: 100000, filter: _filterOf(x), excluded: excl)) if (d.startsWith('− ')) d.substring(2) ]; return (del, excl); } /// 확인 창 (FilterLimitNote 와 체크) 을 거친 뒤 이번 한 번만 지우기 포함으로 맞춘다. 다음 자동 맞추기는 다시 보내기만 Future deleteNow(LiveSyncPair x) async { _deleteOnce.add(keyOf(x)); await syncNow(x); } Future 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 SyncDiff.of(_pendingPair(x), filter: _filterOf(x)); await _countOnlyTarget(_pendingPair(x)); } catch (_) {} notifyListeners(); } /// 244: 지우기 포함이 아닌 쌍에서 대상에만 있는 항목 수 (맞출 것이 없을 때 "같습니다" 대신 사실대로 보이려고) final onlyTarget = {}; Future _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 SyncDiff.of(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 = >{}; /// [toDelete] 가운데 --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더). 확인 창에서 따로 묶어 보인다 /// (관리자 3a 조건 10/20: 사용자가 알고 누르게) final toDeleteExcluded = >{}; /// 원본을 읽지 못했거나 원본이 비어 멈춘 쌍 (68 · 70). 성공하면 지운다. 카드에 빨갛게 · 작업 알림에도 final problems = {}; /// 원본이 비어 멈춘 쌍에서 지워질 항목 (70: 목록을 보고 [그래도 맞추기]) final emptyDeletes = >{}; /// 다음 한 번은 원본이 비어도 맞춘다 (70) final _allowEmpty = {}; /// 70: 원본이 정말 비어 있는 것을 확인했다 → 이번 한 번 그대로 맞춘다 (대상에서 지움) Future syncEmptyAnyway(LiveSyncPair x) async { _allowEmpty.add(keyOf(x)); await syncNow(x); } /// 설정의 쌍을 바꾼다 (같은 원본 → 대상) Future _setPair(LiveSyncPair old, LiveSyncPair now) => c.updateSettings((s) => s.copy.liveSyncPairs = [ for (final p in s.copy.liveSyncPairs) keyOf(p) == keyOf(old) ? now : p, ]); /// 지울 목록을 보고 결정: [delete] true = 지우기 포함으로 맞추기 (확인함), false = 지우기 끄기. 그 뒤 바로 맞춘다 Future 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.copy.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] = (now(), tr('원본 폴더가 없습니다')); continue; } // 대상이 원본 안이면 맞출 때마다 원본이 바뀌어 끝없이 돈다. 34: 원본이 대상 안 (안쪽 → 바깥) 은 지우기가 없을 때만 if (nestingProblem(x, allowInnerToOuter: s.general.allowInnerToOuter) case final nest?) { status[k] = (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.copy.liveSyncIntervalSec), (_) => _schedule(x)); } } else { // Android 등 · 원본이 WebDAV: 폴더 안쪽까지 감시가 안 되므로 정해진 간격으로 살핀다 _timers[k] = Timer.periodic(Duration(seconds: s.copy.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 unreadableOf(LiveSyncPair x) => optionProblems(_optionsOf(x)); Future syncNow(LiveSyncPair x) async { final k = keyOf(x); final bad = unreadableOf(x); final nest = bad.isNotEmpty ? optionProblemMessage(bad) : nestingProblem(x, allowInnerToOuter: c.settings.general.allowInnerToOuter); if (nest != null) { status[k] = (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 = []; final del = [ for (final d in await SyncDiff.of(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.copy.bandwidthKBps); 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 ? (now(), trf('지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요', [toDelete[k]!.length])) : ops.dirConflicts.isNotEmpty ? (now(), trf('{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개', [n < 0 ? 0 : n, ops.dirConflicts.length])) : (now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); problems.remove(k); emptyDeletes.remove(k); pending[k] = await SyncDiff.of(_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] = ( now(), e.locked ? tr('마스터 비밀번호가 필요해 기다리는 중') : e.empty ? tr('원본 폴더가 비어 있어 멈춤') : tr('원본을 읽을 수 없어 멈춤') ); if (e.empty) { try { emptyDeletes[k] = [for (final d in await SyncDiff.of(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) { // F18: 화면에는 화면 언어의 짧은 글 (예외 이름 · 영어 파일 오류 없이), 작업 기록에는 원문 그대로 (아래) status[k] = (now(), trf('실패: {0}', [userErrorText(e, sources: [x.source])])); c.note(trf('실시간 동기화 실패: {0} → {1}: {2}', [x.source, x.target, e])); } finally { _running.remove(k); notifyListeners(); } } /// rsync: 원본 "내용" 을 대상으로 (끝에 / ), 지우기는 --delete. 보낸 파일 수 (모르면 -1) Future _rsync(LiveSyncPair x, AppSettings s) async { final exe = await rsyncExecutable(s); if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)')); final args = SyncCommands.rsyncArgs(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; } Future _robocopy(LiveSyncPair x, AppSettings s) async { final r = await Process.run('robocopy', SyncCommands.robocopyArgs(x, s)); if (!robocopyOk(r.exitCode)) throw ProcessException('robocopy', const [], '${r.stdout}'.trim(), r.exitCode); return RobocopyOutput().feed('${r.stdout}\n').length; } }