JJ_MKVMaker/lib/app/live_sync.dart

693 lines
32 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: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.copy.rsyncSource == 'custom') {
return s.copy.rsyncPath.isNotEmpty && File(s.copy.rsyncPath).existsSync() ? s.copy.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.copy.rsyncSource == 'custom' || bundledRsync != null,
};
/// 실시간 동기화 (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 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.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 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.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 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) 도 실시간 동기화에서는 쓰지 않는다
// 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs)
...rsyncFileOptionArgs([
for (final o in splitOptions(s.copy.rsyncOptions))
if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o,
], windows: windows),
if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
if (s.copy.bandwidthKBps > 0) '--bwlimit=${s.copy.bandwidthKBps}',
if (x.delete) '--delete',
// 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창
// (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름)
if (x.delete && splitOptions(s.copy.rsyncOptions).contains('--delete-excluded')) '--delete-excluded',
...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다
'${toCygwinPath(x.source, windows: windows)}/',
'${rsyncDestPath(x.target, windows: windows)}/', // 받는 쪽 UNC 는 \\서버\… (28 · 15 확인 10/20)
];
/// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97
static List<String> robocopyArgsFor(LiveSyncPair x, AppSettings s) {
final opts = [
for (final o in splitOptions(s.copy.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.copy.bandwidthKBps > 0) '/IPG:${robocopyIpg(s.copy.bandwidthKBps)}',
'/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;
}
}