import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import '../core/file_ops.dart'; import '../core/sync_tools.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import '../platform/windows/windows_usage.dart'; import 'app_controller.dart'; import 'live_sync.dart'; import 'settings.dart'; import 'transfer_job.dart'; /// 복사 · 이동 모니터링 (파일 탐색기 > 모니터링 > 복사 · rsync): /// 복사한 원본 → 대상을 기억 (설정의 copyTasks) 해 진행 · 결과를 보여 주고, 옵션을 고쳐 다시 실행하고, /// 실시간 동기화 (lsync) 로 옮기거나 lsync 에서 가져온다. class CopyCenter extends ChangeNotifier { final AppController c; CopyCenter(this.c); static CopyCenter? _instance; static CopyCenter of(AppController c) => _instance != null && _instance!.c == c ? _instance! : (_instance = CopyCenter(c)); /// 작업 id → 지금 (또는 마지막) 실행 final jobs = {}; List get tasks => c.settings.copyTasks; bool isRunning(String id) => jobs[id] != null && !jobs[id]!.finished; bool get anyRunning => jobs.values.any((j) => !j.finished); /// 새 작업: 지금 설정의 방법 · 옵션으로 CopyTask fresh(List sources, String dest, {bool move = false, bool contents = false, String? method}) { final s = c.settings; final hasDir = sources.any((s) => isDav(s) || FileSystemEntity.isDirectorySync(s)); var m = CopyMethod.of(method ?? (hasDir ? s.copyMethodFolder : s.copyMethodFile)); if (!copyMethodAvailable(m, s)) m = CopyMethod.builtin; return CopyTask( id: '${DateTime.now().microsecondsSinceEpoch}', sources: sources, dest: dest, move: move, contents: contents, method: m.name, options: m == CopyMethod.robocopy ? s.robocopyOptions : s.rsyncOptions, once: s.copyRunMode == 'once', bandwidthKBps: s.copyBandwidthKBps, ); } /// 같은 복사를 기억해 두었으면 그것, 없으면 새 작업 (기억하지는 않음 - 확인 창에 보여 줄 때) CopyTask peek(List sources, String dest, {bool move = false, bool contents = false, String? method}) { final t = fresh(sources, dest, move: move, contents: contents, method: method); return tasks.where((x) => x.key == t.key).firstOrNull ?? t; } /// 같은 복사를 기억해 두었으면 그것 (그 옵션을 쓴다), 없으면 새로 기억 Future remember(List sources, String dest, {bool move = false, bool contents = false, String? method}) async { final t = fresh(sources, dest, move: move, contents: contents, method: method); final old = tasks.where((x) => x.key == t.key).firstOrNull; if (old != null) return old; await c.updateSettings((x) => x.copyTasks = [t, ...x.copyTasks]); notifyListeners(); return t; } Future update(CopyTask t) async { await c.updateSettings((x) => x.copyTasks = [for (final y in x.copyTasks) y.id == t.id ? t : y]); notifyListeners(); } Future remove(String id) async { cancel(id); jobs.remove(id); await c.updateSettings((x) => x.copyTasks = [for (final y in x.copyTasks) if (y.id != id) y]); notifyListeners(); } void cancel(String id) => jobs[id]?.cancel(); /// 실행을 시작하고 그 작업을 바로 돌려준다 (rsync 가 없으면 null). 끝나면 결과를 기억한다 ([TransferJob.done]). /// 실행 · 재개. 원본 · 대상이 없거나 폴더를 자기 안으로 넣는 것이면 시작하지 않고 실패로 남긴다 (null). Future start(CopyTask t, {String? rsyncExe}) async { if (isRunning(t.id)) return jobs[t.id]; final problem = transferProblem(t.sources, t.dest, move: t.move); if (problem != null) { if (tasks.any((x) => x.id == t.id)) { await update(t.copyWith(lastRun: DateTime.now().toIso8601String(), lastResult: 'failed', lastMessage: problem)); } return null; } final method = CopyMethod.of(t.method); // WebDAV 가 끼면 rsync 없이 앱이 직접 맞춘다 final dav = isDav(t.dest) || t.sources.any(isDav); final exe = method == CopyMethod.rsync && !dav ? (rsyncExe ?? await rsyncExecutable(c.settings)) : null; if (method == CopyMethod.rsync && !dav && exe == null) return null; final job = TransferJob( sources: t.sources, dest: t.dest, move: t.move, method: method, options: t.options, once: t.once, bandwidthKBps: t.bandwidthKBps, rsyncExe: exe, contents: t.contents, prune: t.move ? t.prune : '', ); jobs[t.id] = job; job.addListener(notifyListeners); notifyListeners(); unawaited(_runAndRecord(t, job, method)); return job; } Future _runAndRecord(CopyTask t, TransferJob job, CopyMethod method) async { await job.run(); final e = job.error; final cur = tasks.where((x) => x.id == t.id).firstOrNull; if (cur != null) { await update(cur.copyWith( lastRun: DateTime.now().toIso8601String(), lastResult: e == null ? 'done' : e is FileOpCancelled ? 'cancelled' : 'failed', lastMessage: e == null ? (job.pruned > 0 ? trf('빈 폴더 {0}개 지움', [job.pruned]) : '') : '$e', lastFiles: job.allDone, )); } c.note(e == null ? trf('복사 끝 ({0}): {1}개 → {2}', [method.label, t.sources.length, t.dest]) : trf('복사 실패 ({0}): {1}', [method.label, e])); notifyListeners(); } /// lsync 로 옮기기: 원본 폴더마다 (원본 → 대상\이름) 실시간 동기화 쌍. 파일 원본은 넣을 수 없어 남긴다. Future toLiveSync(CopyTask t) async { final dirs = t.sources.where((s) => isDav(s) ? vIsDirSync(s) : FileSystemEntity.isDirectorySync(s)).toList(); if (dirs.isEmpty) return 0; final pairs = [ for (final d in dirs) LiveSyncPair(d, t.contents ? t.dest : p.join(t.dest, p.basename(d)), method: t.method), ]; final rest = t.sources.where((s) => !dirs.contains(s)).toList(); await c.updateSettings((x) => x ..liveSyncPairs = [ ...x.liveSyncPairs, for (final n in pairs) if (!x.liveSyncPairs.any((o) => o.source == n.source && o.target == n.target)) n, ] ..copyTasks = [ for (final y in x.copyTasks) if (y.id != t.id) y else if (rest.isNotEmpty) CopyTask(id: y.id, sources: rest, dest: y.dest, method: y.method, options: y.options, bandwidthKBps: y.bandwidthKBps), ]); notifyListeners(); return pairs.length; } /// lsync 에서 빼서 복사 목록으로 (rsync 로, 원본 "안의 것" → 대상) Future fromLiveSync(LiveSyncPair x) async { final s = c.settings; final m = copyMethodAvailable(CopyMethod.rsync, s) ? 'rsync' : x.method; final t = fresh([x.source], x.target, contents: true, method: m); await c.updateSettings((y) => y ..liveSyncPairs = [for (final o in y.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o] ..copyTasks = [t, ...y.copyTasks]); notifyListeners(); return t; } } /// 디스크 남은 용량 · 전체 용량 (바이트). 알 수 없으면 null. Future<(int, int)?> diskSpace(String path) async { try { // WebDAV: 서버가 알려 주는 남은 용량 · 쓴 용량 (전체 = 남은 + 쓴) if (isDav(path)) { final (free, used) = await DavPath.parse(path).client.quota(); return free == null ? null : (free, free + (used ?? 0)); } if (Platform.isWindows) return WindowsUsage().disk(path); if (Platform.isAndroid) { final r = await const MethodChannel('jj_mkvmaker/android').invokeMethod>('diskSpace', {'path': path}); if (r != null && r.length == 2) return ((r[0] as num).toInt(), (r[1] as num).toInt()); } } catch (_) {} return null; }