import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import '../core/file_ops.dart'; import '../core/sync_preview.dart' show isRsyncDeleteOption; import '../core/sync_tools.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import '../core/rsync_filter.dart'; /// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다: /// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률 /// - 아래쪽: 지금 항목 (폴더) 의 파일 중 몇 개를 했는지 /// 237: 사용자에게 보이는 실패 글 - "ProcessException:" 같은 예외 이름 · "Command:" 줄은 빼고 내용만. /// 원래 글 ('$e') 은 작업 기록에 그대로 남긴다. /// 245: 앱이 직접 할 때의 파일 오류 (Dart 의 "Cannot delete file" 등 영어) 는 화면 언어로. [sources] 안의 파일을 못 지웠으면 /// rsync 때 (215) 와 같은 글 - 이동에서 원본만 남은 것 String userErrorText(Object e, {List sources = const []}) => switch (e) { ProcessException(:final message) when message.isNotEmpty => message, FileSystemException(:final message, :final path, :final osError) => [ _fileErrorText(message, path, sources), if (path != null && path.isNotEmpty) path, if (osError != null && osError.message.isNotEmpty) osError.message, ].join(' · '), _ => '$e'.replaceFirst(RegExp(r'^\w*(Exception|Error)(\([^)]*\))?: '), ''), }; String _fileErrorText(String message, String? path, List sources) { final m = message.toLowerCase(); if (m.startsWith('cannot delete') || m.startsWith('deletion failed')) { final inSource = path != null && sources.any((s) => isSameOrInside(path, s)); return inSource ? tr('원본 파일을 지우지 못했습니다 (권한이 없거나 사용 중). 대상에는 복사됐고 원본은 남아 있습니다.') : tr('지우지 못했습니다 (권한이 없거나 사용 중)'); } if (m.startsWith('cannot copy')) return tr('파일을 복사하지 못했습니다'); if (m.startsWith('cannot rename')) return tr('옮기거나 이름을 바꾸지 못했습니다'); if (m.startsWith('cannot open') || m.startsWith('cannot read')) return tr('파일을 읽지 못했습니다'); if (m.startsWith('cannot create') || m.startsWith('creation failed') || m.startsWith('cannot write')) { return tr('만들거나 쓰지 못했습니다'); } return message; } /// 215: rsync 가 실패했을 때 보일 글 - 원본을 못 지웠으면 쉬운 말 한 줄, 그리고 오류 · 권한 줄을 끝쪽부터 (전송한 파일 이름이 아니라). /// 예전에는 출력의 앞쪽 8줄이라 파일 이름만 보이고 "Permission denied" · "rsync error … (code 23)" 가 빠지기 쉬웠다 String rsyncFailureText(int code, List errLines, List allLines) { final all = [...errLines, ...allLines]; final removeFailed = all.any((l) { final x = l.toLowerCase(); return x.contains('failed to remove') || x.contains('cannot delete') || x.contains('unable to remove'); }); bool important(String l) { final x = l.toLowerCase(); return x.contains('error') || x.contains('fail') || x.contains('denied') || x.contains('cannot') || x.contains('permission'); } final picked = errLines.where(important).toList(); final lines = picked.isNotEmpty ? picked : (errLines.isNotEmpty ? errLines : allLines); final tail = lines.length > 6 ? lines.sublist(lines.length - 6) : lines; return [ if (removeFailed) tr('원본 파일을 지우지 못했습니다 (권한이 없거나 사용 중). 대상에는 복사됐고 원본은 남아 있습니다.'), trf('rsync 종료 코드 {0}', [code]), ...tail, ].join('\n'); } class TransferJob extends ChangeNotifier { final List sources; final String dest; final bool move; final CopyMethod method; /// rsync · robocopy 옵션 글자 final String options; /// 여러 항목을 rsync 한 번으로 (false 면 항목마다 따로) final bool once; final int bandwidthKBps; /// rsync 실행 파일 (rsync 방법일 때) final String? rsyncExe; /// 폴더 "안의 것" 을 [dest] 에 맞추기 (rsync 원본/ → 대상/, 현재 방식은 바뀐 것만). lsync 에서 옮겨 온 복사. final bool contents; /// 이동 뒤 원본 정리: '' 안 함 · 'keep' 빈 폴더 지움 (원본 폴더는 남김) · 'all' 원본 폴더까지 final String prune; /// 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때만 - robocopy · rsync 는 그 프로그램의 규칙) final NameConflict conflict; /// 229: 앱이 직접 맞출 때 · robocopy (/XO) 에서 받는 쪽이 더 새것이면 건너뛴다 (환경 설정 - 옵션 칸이 없는 현재 방식 · WebDAV 도). /// 건너뛴 원본은 이동이라도 남긴다. rsync 는 옵션 칸의 -u 를 따른다 final bool skipNewer; /// 같은 이름이라 건너뛴 항목 (원본 경로) final skipped = []; /// M1 (10/19): 원격 맞추기에서 받는 쪽에 같은 이름의 폴더가 있어 건너뛴 것 (--delete 가 없을 때) final dirConflicts = []; TransferJob({ required this.sources, required this.dest, required this.move, this.method = CopyMethod.builtin, this.options = '', this.once = false, this.bandwidthKBps = 0, this.rsyncExe, this.contents = false, this.prune = '', this.conflict = NameConflict.rename, this.skipNewer = false, }); /// 지금 복사하는 파일 (원본 기준 상대 경로, rsync 는 이름을 먼저 알리고 그 파일의 진행률을 알린다) String currentFile = ''; /// 지금 전송 속도 (바이트/초, 모르면 null): rsync 는 -P 가 알려 주는 값, 현재 방식은 최근 3초 동안 복사한 양 double? speed; int _bytes = 0; final _samples = <(DateTime, int)>[]; void _addBytes(int n) { final now = DateTime.now(); _bytes += n; _samples ..add((now, _bytes)) ..removeWhere((x) => now.difference(x.$1).inMilliseconds > 3000); if (_samples.length >= 2) { final ms = now.difference(_samples.first.$1).inMilliseconds; if (ms > 0) speed = (_bytes - _samples.first.$2) * 1000 / ms; } // 큰 파일 하나를 보내는 동안에도 진행 막대가 오르게 (파일이 끝날 때만 알리면 0% 에 머문다) - 0.2초마다 if (now.difference(_lastTick).inMilliseconds >= 200) { _lastTick = now; _tick(); } } DateTime _lastTick = DateTime.fromMillisecondsSinceEpoch(0); /// 원본 정리 중 · 지운 빈 폴더 수 bool pruning = false; int pruned = 0; // ── 진행 상태 ── int index = 0; // 지금 항목 int get total => sources.length; // 171: 끝난 뒤에도 마지막 항목 이름 (빈 이름으로 " 안의 파일" 이 되던 것) String get currentName => sources.isEmpty ? '' : p.basename(sources[index < sources.length ? index : sources.length - 1]); late final List filesTotal = List.filled(sources.length, 0); late final List filesDone = List.filled(sources.length, 0); /// 진행 표시 (한 파일 0% 에 머물던 것): 앱이 직접 복사할 때 항목마다 바이트 크기와 보낸 바이트로 진행률을 센다. /// 같은 드라이브에서 이름 바꾸기로 옮기면 보낸 바이트가 없으므로 파일 수로 late final List itemBytes = List.filled(sources.length, 0); int _itemStart = 0; int get _bytesTotal => itemBytes.fold(0, (a, b) => a + b); bool get _byBytes => _bytesTotal > 0 && _bytes > 0; /// 지금 파일의 진행률 (rsync -P · robocopy 가 알려 줄 때) double? filePercent; bool counting = true; bool finished = false; Object? error; bool cancelled = false; final made = []; /// 82: 앱이 하나씩 복사 · 이동한 원본 파일 (폴더를 합칠 때 실제로 옮긴 것만 되돌리려고 - 이름 바꾸기로 통째 옮긴 것은 [made] 만) final movedFiles = []; /// 마지막 출력 몇 줄 (실패했을 때 보여 줌) final log = []; /// rsync 가 알려 주는 폴더 전체 확인 (to-chk) - 바뀐 파일만 이름이 나오므로 파일 수보다 정확하다. 모르면 0. int checked = 0, checkTotal = 0; /// 폴더 전체 진행률 (rsync 는 to-chk, 그 밖은 [overall]) double get folderProgress { if (finished) return 1; if (checkTotal > 0) return (checked / checkTotal).clamp(0.0, 1.0); return overall; } int get allFiles => filesTotal.fold(0, (a, b) => a + b); int get allDone => filesDone.fold(0, (a, b) => a + b); /// 216: 이동이 끝난 뒤 원본에 남은 파일 수 (건너뛴 것) int leftInSource = 0; /// 229: 폴더를 합칠 때 받는 쪽이 더 새것이라 건너뛴 파일 수 (복사 · 이동) int keptNewer = 0; /// 235: 끝난 이동에서 실제로 옮긴 파일 수 (원본에 남은 것은 뺀다 - robocopy 는 건너뛴 것도 다 한 것으로 센다) int get doneFiles => move && finished ? (allFiles - leftInSource).clamp(0, allFiles) : allDone; /// 212 · 216: 끝 알림 한 줄 - "이동 끝: n개 · 원본 → 대상 · m개는 원본에 남음 (건너뜀)" / "복사 끝: n개 · 원본 → 대상" String doneSummary() { final from = sources.length == 1 ? vBasename(sources.first) : trf('{0}개 항목', [sources.length]); final total = filesTotal.fold(0, (a, b) => a + b); final n = move ? (total - leftInSource).clamp(0, total) : allDone; final head = move ? trf('이동 끝: {0}개 · {1} → {2}', [n, from, vBasename(dest)]) : trf('복사 끝: {0}개 · {1} → {2}', [n, from, vBasename(dest)]); if (leftInSource > 0) return '$head · ${trf('{0}개는 원본에 남음 (건너뜀)', [leftInSource])}'; // 229: 복사에서 받는 쪽이 더 새것이라 건너뛴 것 return keptNewer > 0 ? '$head · ${trf('받는 쪽이 더 새것 {0}개 건너뜀', [keptNewer])}' : head; } /// 위쪽 진행률: 끝낸 항목 + 지금 항목의 파일 진행 double get overall { if (finished) return 1; if (total == 0) return 0; if (_byBytes) return (_bytes / _bytesTotal).clamp(0.0, 1.0); final cur = index < total && filesTotal[index] > 0 ? (filesDone[index] / filesTotal[index]).clamp(0.0, 1.0) : 0.0; return ((index + cur) / total).clamp(0.0, 1.0); } /// 아래쪽 진행률: 지금 항목의 파일 double get current { if (finished) return 1; if (index >= total) return 0; if (_byBytes && itemBytes[index] > 0) return ((_bytes - _itemStart) / itemBytes[index]).clamp(0.0, 1.0); if (filesTotal[index] == 0) return 0; return (filesDone[index] / filesTotal[index]).clamp(0.0, 1.0); } Process? _proc; FileOps? _ops; final _done = Completer(); /// 끝날 때 (성공 · 실패 · 취소) Future get done => _done.future; void cancel() { cancelled = true; _ops?.cancel(); _proc?.kill(); notifyListeners(); } void _tick() => notifyListeners(); /// 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 직접 (크기 · 시각 비교) 맞춘다 bool get viaWebDav => isDav(dest) || sources.any(isDav); /// rsync -u (--update) 를 옵션에 넣었는지 (앱이 맞출 때도 같은 뜻으로: 대상이 더 새것이면 건너뜀) bool get _update => splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u'))); /// 100: WebDAV 를 앱이 맞출 때도 rsync 의 지우기 (--delete 계열) 를 따른다 (확인 창에서 지울 목록을 본 뒤에만 실행됨) bool get _delete => method == CopyMethod.rsync && splitOptions(options).any(isRsyncDeleteOption); static Future countFiles(String path) async { if (isDav(path)) return vCountFiles(path); if (!FileSystemEntity.isDirectorySync(path)) return 1; var n = 0; await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) { if (e is File) n++; } return n; } Future run() async { try { // 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 실행하지 않는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐) if (method == CopyMethod.rsync) { final bad = optionProblems(options); if (bad.isNotEmpty) throw StateError(optionProblemMessage(bad)); } for (var i = 0; i < sources.length; i++) { final s = sources[i]; if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) { throw FileSystemException(tr('폴더를 자기 안으로 복사 · 이동할 수 없습니다'), s); } filesTotal[i] = await countFiles(s); // 앱이 직접 복사할 때만 (robocopy · rsync 는 그 프로그램이 진행을 알려 줌) if (viaWebDav || method == CopyMethod.builtin) { try { itemBytes[i] = await FileOps.totalSize([s]); } catch (_) {} } } counting = false; _tick(); switch (viaWebDav ? CopyMethod.builtin : method) { case CopyMethod.builtin: await _runBuiltin(); case CopyMethod.rsync: await _runRsync(); case CopyMethod.robocopy: await _runRobocopy(); } if (cancelled) throw const FileOpCancelled(); // 옮긴 뒤 원본에 남은 빈 폴더 정리 (find 원본/ -type d -empty -delete) if (move && prune.isNotEmpty) { pruning = true; _tick(); for (final s in sources) { if (isDav(s) || FileSystemEntity.isDirectorySync(s)) pruned += await removeEmptyDirs(s, keepRoot: prune == 'keep'); } } // 216: 옮긴 뒤 원본에 남은 파일을 다시 센다 (옵션 -u · --exclude · 대상이 더 새것 등으로 건너뜀, 맞춘 뒤 바뀜) - // 예전에는 남은 것이 있어도 "끝" 으로만 보였다 if (move) { for (final s in sources) { try { if (await vExists(s)) leftInSource += await countFiles(s); } catch (_) {} } } } catch (e) { error = cancelled ? const FileOpCancelled() : e; } finally { finished = true; speed = null; _tick(); if (!_done.isCompleted) _done.complete(); } } Future _runBuiltin() async { for (index = 0; index < sources.length; index++) { if (cancelled) return; final i = index; _itemStart = _bytes; _ops = FileOps( bandwidthKBps: bandwidthKBps, onFileDone: (src) { filesDone[i]++; movedFiles.add(src); currentFile = vBasename(src); _tick(); }, // 보낸 양으로 전송 속도 (WebDAV 포함) onBytes: _addBytes, conflict: conflict, // 229: 폴더를 합칠 때도 안쪽의 더 새 파일은 건너뜀 skipNewerInMerge: skipNewer, // 214: 이동은 맞춘 원본 파일만 지운다 onSettled: move ? (s, size, modified) => settled[s] = (size, modified) : null, ); _tick(); if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) { // 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도 settled.clear(); await _ops!.mirror(sources[i], dest, update: _update || skipNewer, delete: _delete, strict: move, // 관리자 지시 (10/19): rsync 를 고른 작업 (원격이라 앱이 맞춤) 은 옵션의 거르기도 미리 보기와 같게 filter: method == CopyMethod.rsync ? RsyncFilter.parse(options) : RsyncFilter.none); dirConflicts.addAll(_ops!.dirConflicts); _ops!.dirConflicts.clear(); if (move && !cancelled) await _deleteSettled(); made.add(dest); } else { final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest); made.addAll(r); skipped.addAll(_ops!.skipped); keptNewer += _ops!.keptNewer.length; } filesDone[i] = filesTotal[i]; // 이름 바꾸기로 옮긴 경우 } index = sources.length; } /// 214: 이번 맞추기에서 대상에 맞춘 원본 파일 (경로 → 그때의 크기 · 바뀐 때) final settled = {}; /// 맞춘 뒤 원본의 파일을 지운다 (rsync --remove-source-files 처럼, 빈 폴더는 [prune] 이 정리). /// 214: 맞춘 파일만 - 건너뛴 것 (-u 로 대상이 더 새것) · 맞추는 동안 새로 생긴 것은 남긴다. /// 맞춘 뒤 바뀐 것 (크기 · 바뀐 때가 다름) 도 남긴다. 예전에는 원본 폴더를 다시 읽어 모든 파일을 지웠다 Future _deleteSettled() async { for (final MapEntry(key: path, value: (size, modified)) in settled.entries) { if (cancelled) return; final now = await vStat(path); if (now == null || now.isDir) continue; if (now.size != size || now.modified.difference(modified).inSeconds.abs() > 2) { skippedSources.add(path); continue; } await vDelete(path); } } /// 214: 이동인데 원본에 남긴 파일 (맞춘 뒤 바뀜) final skippedSources = []; /// 215: 프로그램이 오류 출력 (stderr) 으로 낸 줄 (끝의 20줄) final errLog = []; void _addLog(String line) { if (line.trim().isEmpty) return; log.add(line.trim()); if (log.length > 20) log.removeAt(0); } Future _start(String exe, List args, Encoding enc, void Function(String chunk) onOut) async { _proc = await Process.start(exe, args, runInShell: false); final a = _proc!.stdout.transform(enc.decoder).listen(onOut); final b = _proc!.stderr.transform(enc.decoder).listen((t) { for (final l in t.split(RegExp(r'[\r\n]'))) { _addLog(l); // 215: 오류 줄은 따로 (전송한 파일 이름에 밀려 실패 이유가 빠지지 않게) if (l.trim().isNotEmpty) { errLog.add(l.trim()); if (errLog.length > 20) errLog.removeAt(0); } } }); final code = await _proc!.exitCode; await a.cancel(); await b.cancel(); _proc = null; return code; } Future _runRsync() async { final exe = rsyncExe; if (exe == null || exe.isEmpty) throw StateError(tr('rsync 실행 파일이 없습니다')); final windows = Platform.isWindows; Future one(List idx) async { final out = RsyncOutput(); final names = [for (final i in idx) p.basename(sources[i])]; final code = await _start( exe, rsyncArgs( options: options, sources: [for (final i in idx) sources[i]], dest: dest, bandwidthKBps: bandwidthKBps, move: move, windows: windows, contents: contents), utf8, (chunk) { for (final f in out.feed(chunk)) { _addLog(f); currentFile = f; // 출력 경로의 첫 부분 = 원본 이름 → 어느 항목인지 (안의 것 모드는 이름이 없어 지금 항목) final first = f.split('/').first; var k = contents ? -1 : idx.indexWhere((i) => p.basename(sources[i]) == first); if (k < 0) k = names.indexOf(f); final at = k < 0 ? index : idx[k]; if (at > index) index = at; filesDone[at]++; } filePercent = out.currentPercent; speed = out.currentSpeed ?? speed; if (out.checkTotal != null) { checkTotal = out.checkTotal!; checked = out.checked!; } _tick(); }, ); if (cancelled) return; if (!rsyncOk(code)) throw ProcessException(exe, const [], rsyncFailureText(code, errLog, log), code); for (final i in idx) { filesDone[i] = filesTotal[i]; made.add(p.join(dest, p.basename(sources[i]))); // 폴더째 이동 (안의 것 [contents] 이 아니고 원본 정리 [prune] 를 고르지 않음): rsync 는 파일만 지우므로 // 비어 남은 원본 폴더를 지운다. 원본 정리를 고른 이동은 끝에서 그 고른 대로 ([prune]). if (move && !contents && prune.isEmpty && FileSystemEntity.isDirectorySync(sources[i]) && await countFiles(sources[i]) == 0) { await Directory(sources[i]).delete(recursive: true); } } } if (once) { index = 0; await one([for (var i = 0; i < sources.length; i++) i]); } else { for (index = 0; index < sources.length; index++) { if (cancelled) return; _tick(); await one([index]); } } index = sources.length; } Future _runRobocopy() async { // robocopy 는 폴더마다 (파일은 같은 폴더끼리) 한 번씩 for (index = 0; index < sources.length; index++) { if (cancelled) return; final s = sources[index]; final dir = FileSystemEntity.isDirectorySync(s); final runs = robocopyRuns( options: options, folders: dir ? [s] : const [], files: dir ? const [] : [s], dest: dest, bandwidthKBps: bandwidthKBps, move: move, contents: contents, skipNewer: skipNewer, ); _tick(); for (final args in runs) { final out = RobocopyOutput(); final i = index; final code = await _start( 'robocopy', [...args, '/BYTES', '/NJH', '/NJS', '/NDL', '/FP'], systemEncoding, (chunk) { for (final f in out.feed(chunk)) { _addLog(f); filesDone[i]++; } filePercent = out.currentPercent; _tick(); }, ); if (cancelled) return; if (!robocopyOk(code)) throw ProcessException('robocopy', const [], trf('robocopy 종료 코드 {0}', [code]), code); } filesDone[index] = filesTotal[index]; made.add(p.join(dest, p.basename(s))); } index = sources.length; } }