JJ_MKVMaker/lib/app/live_sync.dart

465 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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';
export 'sync/sync_commands.dart' show bundledRsync, findBundledRsync, rsyncExecutable, copyMethodAvailable;
/// 실시간 동기화 (lsyncd 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다.
/// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [CopySettings.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;
/// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [CopySettings.liveSyncOnStart] 를 따른다)
final paused = <String>{};
/// [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<LiveSyncPair> 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<LiveSyncPair> 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<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.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<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 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 = <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 SyncDiff.of(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);
}
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 SyncDiff.of(_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 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 = <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.copy.liveSyncPairs = [
for (final p in s.copy.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.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] = (DateTime.now(), tr('원본 폴더가 없습니다'));
continue;
}
// 대상이 원본 안이면 맞출 때마다 원본이 바뀌어 끝없이 돈다. 34: 원본이 대상 안 (안쪽 → 바깥) 은 지우기가 없을 때만
if (nestingProblem(x, allowInnerToOuter: s.general.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.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<OptionProblem> unreadableOf(LiveSyncPair x) => optionProblems(_optionsOf(x));
Future<void> 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] = (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 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
? (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 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] = (
DateTime.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) {
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 = 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<int> _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;
}
}