import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:archive/archive_io.dart'; import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'file_ops.dart' show NameConflict, nameProblem; import '../services/platform_info.dart'; /// 86: 압축 종류 (확장자로) enum ArchiveKind { zip, sevenZip, rar } ArchiveKind? archiveKindOf(String path) => switch (p.extension(path).toLowerCase()) { '.zip' || '.cbz' => ArchiveKind.zip, '.7z' || '.cb7' => ArchiveKind.sevenZip, '.rar' || '.cbr' => ArchiveKind.rar, _ => null, }; /// 압축 안의 항목 하나 (이름은 압축 안 그대로 - 나누는 글자는 '/' 로 맞춤) class ArchiveEntry { final String name; final int size; final bool isDir; final bool isLink; final bool encrypted; /// 7-Zip 에 넘길 원래 이름 (Windows 의 7z 는 '\' 로 보여 준다) final String raw; /// 압축 안 파일의 바뀐 날 (모르면 null) - 덮어쓰기에서 받는 쪽이 더 새것인지 볼 때 final DateTime? modified; const ArchiveEntry(this.name, {this.size = 0, this.isDir = false, this.isLink = false, this.encrypted = false, String? raw, this.modified}) : raw = raw ?? name; } /// 비밀번호가 필요하거나 틀림 class ArchivePasswordException implements Exception { final bool wrong; const ArchivePasswordException({this.wrong = false}); @override String toString() => wrong ? tr('비밀번호가 틀렸습니다') : tr('비밀번호가 필요합니다'); } /// 86 (보안 · 관리자 판단): 풀면 대상 폴더 밖으로 나가는 이름인지 - 까닭 (괜찮으면 null). /// ../ · 절대 경로 (/ · \) · 드라이브 문자 (C:) · UNC (\\서버) · 이름 안의 ':' (Windows 의 다른 데이터 흐름) 을 막는다 (zip slip) String? unsafeArchivePath(String name) { final n = name.replaceAll('\\', '/'); if (n.startsWith('//')) return tr('네트워크 경로 (UNC)'); if (n.startsWith('/')) return tr('절대 경로'); if (RegExp(r'^[A-Za-z]:').hasMatch(n)) return tr('드라이브 경로'); final parts = n.split('/'); if (parts.any((s) => s == '..')) return tr('바깥 폴더 (../)'); if (parts.any((s) => s.contains(':'))) return tr('쓸 수 없는 글자 (:)'); if (parts.every((s) => s.isEmpty || s == '.')) return tr('빈 이름'); return null; } /// 풀기 전 검사 결과 class ExtractPlan { /// 풀 파일 (폴더는 파일을 풀며 만든다) final List files; /// 막은 것: (이름, 까닭) final List<(String, String)> unsafe; /// 만들지 않고 건너뛰는 링크 final List links; /// 이 기기에서 쓸 수 없는 이름 (Windows: CON · NUL · ? * " < > | · 끝 점 · 빈칸 …) - (이름, 까닭), 건너뜀 final List<(String, String)> badNames; /// 풀린 크기 합 (바이트) final int totalSize; /// 비밀번호가 걸린 항목이 있음 final bool encrypted; const ExtractPlan(this.files, this.unsafe, this.links, this.totalSize, this.encrypted, [this.badNames = const []]); } /// 목록 [entries] 에서 풀 것을 고른다. [names] 가 있으면 그 이름 (폴더면 그 안 모두) 만 /// 15 지적 · 관리자 판단: 리눅스 · 맥에서 만든 압축의 이름이 Windows 에서 쓸 수 없으면 (78 의 nameProblem) 미리 걸러 건너뛴다 ExtractPlan planExtract(List entries, {List names = const [], bool? windowsRules}) { final win = windowsRules ?? PlatformInfo.host.isWindows; bool wanted(String n) => names.isEmpty || names.any((x) => n == x || n.startsWith(x.endsWith('/') ? x : '$x/')); final files = []; final unsafe = <(String, String)>[]; final links = []; final badNames = <(String, String)>[]; var size = 0; var enc = false; for (final e in entries) { if (!wanted(e.name)) continue; final bad = unsafeArchivePath(e.name); if (bad != null) { unsafe.add((e.name, bad)); continue; } if (e.isLink) { links.add(e.name); continue; } if (e.isDir) continue; // 이름의 각 부분이 이 기기에서 쓸 수 있는지 (풀다가 실패하거나 7-Zip 이 다른 이름으로 바꾸지 않게) final why = [for (final s in e.name.split('/')) if (s.isNotEmpty && s != '.') nameProblem(s, windowsRules: win)].nonNulls.firstOrNull; if (why != null) { badNames.add((e.name, why)); continue; } files.add(e); size += e.size; enc |= e.encrypted; } return ExtractPlan(files, unsafe, links, size, enc, badNames); } /// 풀 때 [dest] 안의 자리 ([name] 은 [unsafeArchivePath] 를 지난 것) String extractTarget(String dest, String name) => p.joinAll([dest, ...name.replaceAll('\\', '/').split('/').where((s) => s.isNotEmpty && s != '.')]); /// 같은 이름이면 "이름 (2).확장자" … String _freeName(String path) { if (FileSystemEntity.typeSync(path) == FileSystemEntityType.notFound) return path; final dir = p.dirname(path), base = p.basenameWithoutExtension(path), ext = p.extension(path); for (var n = 2;; n++) { final c = p.join(dir, '$base ($n)$ext'); if (FileSystemEntity.typeSync(c) == FileSystemEntityType.notFound) return c; } } /// 푼 결과 class ExtractResult { int done = 0; final skippedExisting = []; final renamed = []; /// 덮어쓰기인데 받는 쪽이 더 새것이라 그대로 둔 것 (229 와 같은 규칙) final keptNewer = []; } /// 덮어쓰기에서 건너뛸지: 받는 쪽 파일이 압축 안의 것보다 2초 넘게 새것 bool _targetNewer(String target, DateTime? modified) { if (modified == null) return false; try { return File(target).lastModifiedSync().isAfter(modified.add(const Duration(seconds: 2))); } catch (_) { return false; } } /// ZIP 의 MS-DOS 날짜 · 시각 (지역 시각) → DateTime (0 이면 null) DateTime? dosDateTime(int date, int time) { if (date == 0) return null; return DateTime(1980 + (date >> 9), (date >> 5) & 0xF, date & 0x1F, time >> 11, (time >> 5) & 0x3F, (time & 0x1F) * 2); } /// 취소 (풀기 · 묶기 도중) class ArchiveCancelled implements Exception { const ArchiveCancelled(); @override String toString() => tr('취소했습니다'); } // ───────── ZIP (archive 패키지, MIT) ───────── /// ZIP 목록 - 가운데 목록 (central directory) 을 직접 읽어 비밀번호 · 링크를 안다 /// (archive 패키지의 항목에는 비밀번호 여부가 없고, 비밀번호 없이 읽으면 암호 글자가 그대로 나온다) Future> listZip(String path) async { final input = InputFileStream(path); try { final dir = ZipDirectory()..read(input); return [ for (final h in dir.fileHeaders) ArchiveEntry(h.filename.replaceAll('\\', '/'), size: h.uncompressedSize, isDir: h.filename.endsWith('/') || h.filename.endsWith('\\'), // 유닉스 파일 종류 (상위 16비트) 가 심볼릭 링크 (0xA000) isLink: (h.externalFileAttributes >> 16) & 0xF000 == 0xA000, encrypted: h.generalPurposeBitFlag & 0x1 != 0, modified: dosDateTime(h.lastModifiedFileDate, h.lastModifiedFileTime)), ]; } finally { await input.close(); } } /// ZIP 풀기 - [plan] 의 파일만, 같은 이름은 [conflict] 대로 /// zip_aes: ZIP 비밀번호를 UTF-8 바이트로 (한 바이트 = 한 글자). archive 4.3.0 은 비밀번호를 password.codeUnits 를 바이트로 잘라 써서 /// 한글 등이 깨지고 7-Zip (UTF-8) 과 맞지 않았다 - UTF-8 바이트를 글자로 넣으면 archive 가 그 바이트를 그대로 쓴다 String zipPasswordBytes(String password) => String.fromCharCodes(utf8.encode(password)); /// ZIP 풀기 ([_extractZipPlanWith]). 비밀번호에 영문 밖 글자가 있으면 UTF-8 (7-Zip · 이 앱이 만든 것) 로 먼저, /// 틀리면 예전 방식 (이 앱의 예전 판 · archive 그대로) 으로 한 번 더 Future extractZipPlan(String path, String dest, ExtractPlan plan, {NameConflict conflict = NameConflict.rename, bool skipNewer = false, String? password, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async { final tries = [ if (password != null && password.isNotEmpty) zipPasswordBytes(password), if (password != null && password.isNotEmpty && zipPasswordBytes(password) != password) password, if (password == null || password.isEmpty) password, ]; for (var i = 0;; i++) { try { return await _extractZipPlanWith(path, dest, plan, conflict: conflict, skipNewer: skipNewer, password: tries[i], onProgress: onProgress, cancelled: cancelled); } on ArchivePasswordException catch (e) { if (!e.wrong || i + 1 >= tries.length) rethrow; } } } Future _extractZipPlanWith(String path, String dest, ExtractPlan plan, {NameConflict conflict = NameConflict.rename, bool skipNewer = false, String? password, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async { // 비밀번호가 걸린 항목인데 비밀번호가 없으면 (암호 글자를 그대로 쓰지 않게) 먼저 묻는다 if (plan.encrypted && (password == null || password.isEmpty)) throw const ArchivePasswordException(); final input = InputFileStream(path); final r = ExtractResult(); try { // verify: CRC 를 맞춰 보아 비밀번호가 틀리면 (풀린 내용이 다르면) 실패로 final a = ZipDecoder().decodeStream(input, password: password, verify: plan.encrypted); final want = {for (final e in plan.files) e.name}; final modified = {for (final e in plan.files) e.name: e.modified}; final todo = [for (final f in a.files) if (f.isFile && want.contains(f.name.replaceAll('\\', '/'))) f]; for (final f in todo) { if (cancelled?.call() ?? false) throw const ArchiveCancelled(); var target = extractTarget(dest, f.name); if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) { if (conflict == NameConflict.skip) { r.skippedExisting.add(f.name); continue; } if (conflict == NameConflict.rename) { target = _freeName(target); r.renamed.add(f.name); } // 관리자 판단: 덮어쓰기에도 "받는 쪽이 더 새것이면 건너뛰기" (229) if (conflict == NameConflict.overwrite && skipNewer && _targetNewer(target, modified[f.name.replaceAll('\\', '/')])) { r.keptNewer.add(f.name); continue; } } await Directory(p.dirname(target)).create(recursive: true); final bytes = f.readBytes(); if (bytes == null) continue; // 반쯤 쓴 파일이 완성된 이름으로 남지 않게 (.jjpart 에 쓴 뒤 이름 바꾸기) final part = File('$target.jjpart'); await part.writeAsBytes(bytes, flush: true); if (await File(target).exists()) await File(target).delete(); await part.rename(target); r.done++; onProgress?.call(r.done, todo.length); } } on ArchivePasswordException { rethrow; } catch (e) { // archive 는 틀린 비밀번호를 ArchiveException · 그냥 Exception ("password error") 으로 알린다 if (plan.encrypted && (e is ArchiveException || '$e'.toLowerCase().contains('password'))) { throw const ArchivePasswordException(wrong: true); } rethrow; } finally { await input.close(); } return r; } /// ZIP 만들기: [sources] (파일 · 폴더) 를 [zipPath] 로. 링크 · junction 은 넣지 않는다 (밖을 가리키는 것을 따라가지 않게). /// [level] 0 (묶기만) ~ 9 (가장 작게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움). /// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수) Future<(int, int)> createZip(List sources, String zipPath, {int level = 6, String? password, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async { // 넣을 파일 (상대 이름) - 링크는 세기만 final files = <(String, String)>[]; var links = 0; for (final s in sources) { final base = p.dirname(s); final t = FileSystemEntity.typeSync(s, followLinks: false); if (t == FileSystemEntityType.link) { links++; } else if (t == FileSystemEntityType.file) { files.add((s, p.relative(s, from: base))); } else if (t == FileSystemEntityType.directory) { await for (final e in Directory(s).list(recursive: true, followLinks: false)) { if (e is Link || FileSystemEntity.isLinkSync(e.path)) { links++; } else if (e is File) { files.add((e.path, p.relative(e.path, from: base))); } } } } final part = '$zipPath.jjpart'; // zip_aes: [password] 가 있으면 archive 의 AES-256 (WinZip AE) - 파일마다 메모리에 올려 암호화하므로 큰 파일은 [createZipAes] 가 막는다 final enc = ZipFileEncoder(password: password == null || password.isEmpty ? null : zipPasswordBytes(password))..create(part, level: level); var done = false; try { for (final (i, (path, rel)) in files.indexed) { if (cancelled?.call() ?? false) throw const ArchiveCancelled(); await enc.addFile(File(path), rel.replaceAll('\\', '/'), level); onProgress?.call(i + 1, files.length); } await enc.close(); done = true; await File(part).rename(zipPath); } finally { if (!done) { try { await enc.close(); } catch (_) {} try { await File(part).delete(); } catch (_) {} } } return (files.length, links); } // ───────── 7z · RAR (7-Zip 25.01 실행 파일, LGPL 2.1+ · unRAR 제한 - 풀기만) ───────── /// zip_aes: 영문 밖 글자가 든 비밀번호로 (archive, 메모리에서) 암호화할 수 있는 파일 하나의 크기 한도 const aesInMemoryLimit = 512 * 1024 * 1024; /// zip_aes: 영문 밖 글자가 든 비밀번호인데 [aesInMemoryLimit] 넘는 파일이 있음 class ZipPasswordTooBigException implements Exception { final String name; const ZipPasswordTooBigException(this.name); @override String toString() => trf('비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.', [name]); } /// zip_aes (관리자 지시 10/17): 비밀번호 ZIP (AES-256) 을 동봉 7-Zip 으로 만든다 (Windows 7z.exe · Android lib7zz). /// archive 패키지의 AES 는 파일마다 통째로 메모리에 올려 암호화해 큰 동영상에서 메모리가 모자랄 수 있어 7-Zip 으로 (흘려 가며). /// 넣을 파일은 [createZip] 과 같게 앱이 고른다 (링크 · junction 빼고, 원본 폴더 기준 상대 이름). 비밀번호는 표준 입력으로만 /// (명령 줄 · 작업 기록 · 프로세스 목록에 남지 않게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움). /// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수) Future<(int, int)> createZipAes(String exe, List sources, String zipPath, {required String password, int level = 6, void Function(double v)? onProgress, bool Function()? cancelled}) async { if (password.isEmpty) throw ArgumentError('password'); // Windows 7-Zip 은 표준 입력으로 받은 비밀번호에 영문 밖 글자 (한글 등) 가 있으면 "매개 변수가 틀립니다" 로 실패한다 // (코드 페이지를 바꿔도 같음, 10/17 시험). 그때는 archive 의 AES-256 으로 - 파일을 통째로 메모리에 올리므로 큰 파일은 막고 알린다 (내 판단) if (password.codeUnits.any((c) => c < 0x20 || c > 0x7e)) { for (final s in sources) { for (final f in [ if (FileSystemEntity.isFileSync(s)) File(s), if (FileSystemEntity.isDirectorySync(s)) ...Directory(s).listSync(recursive: true, followLinks: false).whereType(), ]) { if (f.lengthSync() > aesInMemoryLimit) throw ZipPasswordTooBigException(p.basename(f.path)); } } return createZip(sources, zipPath, level: level, password: password, cancelled: cancelled, onProgress: (d, t) => onProgress?.call(t == 0 ? 0 : d / t)); } // 원본 폴더마다 (상대 이름이 그 폴더 기준이 되게) 넣을 파일 목록 final groups = >{}; var links = 0, count = 0; for (final s in sources) { final base = p.dirname(s); final t = FileSystemEntity.typeSync(s, followLinks: false); if (t == FileSystemEntityType.link) { links++; } else if (t == FileSystemEntityType.file) { groups.putIfAbsent(base, () => []).add(p.relative(s, from: base)); } else if (t == FileSystemEntityType.directory) { await for (final e in Directory(s).list(recursive: true, followLinks: false)) { if (e is Link || FileSystemEntity.isLinkSync(e.path)) { links++; } else if (e is File) { groups.putIfAbsent(base, () => []).add(p.relative(e.path, from: base)); } } } } for (final l in groups.values) { count += l.length; } final part = '$zipPath.jjpart'; final tmp = await Directory.systemTemp.createTemp('jj_7za_'); var done = false; try { var g = 0; for (final MapEntry(key: base, value: rels) in groups.entries) { if (cancelled?.call() ?? false) throw const ArchiveCancelled(); final list = File(p.join(tmp.path, 'list$g.txt')); await list.writeAsString(rels.join('\n'), encoding: utf8); final before = g; final (code, out, err) = await _run7z( exe, ['a', '-tzip', '-mem=AES256', '-mcu=on', '-mx=${level.clamp(0, 9)}', '-p', '-bsp1', '-bb0', '-y', part, '@${list.path}'], password: password, confirm: true, workingDirectory: base, cancelled: cancelled, onLine: (l) { final m = RegExp(r'(\d+)%').firstMatch(l); if (m != null) onProgress?.call((before + int.parse(m[1]!) / 100) / groups.length); }); if (code != 0) throw ProcessException(exe, const ['a'], err.trim().isEmpty ? out.trim() : err.trim(), code); g++; } if (count == 0) throw FileSystemException(tr('묶을 파일이 없습니다'), sources.first); done = true; await File(part).rename(zipPath); } finally { if (!done) { try { await File(part).delete(); } catch (_) {} } try { await tmp.delete(recursive: true); } catch (_) {} } return (count, links); } /// 7-Zip 의 "l -slt" 출력 → 목록. 링크: "Symbolic Link" · "Hard Link" 칸이 있거나 속성에 유닉스 링크 (-l…) · 재분석 지점 List parse7zList(String out) { final entries = []; for (final block in out.replaceAll('\r\n', '\n').split(RegExp(r'\n\s*\n'))) { final m = {}; for (final line in block.split('\n')) { final i = line.indexOf(' = '); if (i > 0) m[line.substring(0, i).trim()] = line.substring(i + 3); } final raw = m['Path']; if (raw == null || raw.isEmpty) continue; final attr = m['Attributes'] ?? ''; final link = (m['Symbolic Link'] ?? '').isNotEmpty || (m['Hard Link'] ?? '').isNotEmpty || RegExp(r'(^|\s)-?l[r-][w-][x-]').hasMatch(attr) || attr.contains('REPARSE'); entries.add(ArchiveEntry(raw.replaceAll('\\', '/'), raw: raw, size: int.tryParse(m['Size'] ?? '') ?? 0, modified: DateTime.tryParse((m['Modified'] ?? '').replaceFirst(' ', 'T')), isDir: (m['Folder'] ?? '') == '+' || RegExp(r'^[A-Z_]*D').hasMatch(attr), isLink: link, encrypted: (m['Encrypted'] ?? '') == '+')); } return entries; } /// 7-Zip 실행. 비밀번호는 명령 줄이 아니라 표준 입력으로만 (작업 기록 · 프로세스 목록에 남지 않게 - 관리자 판단) Future<(int, String, String)> _run7z(String exe, List args, {String? password, void Function(String line)? onLine, bool Function()? cancelled, String? workingDirectory, bool confirm = false}) async { final pr = await Process.start(exe, ['-sccUTF-8', '-scsUTF-8', ...args], workingDirectory: workingDirectory); // -sccUTF-8 이면 7-Zip 은 표준 입력도 UTF-8 로 읽는다 - Dart 의 기본 (시스템 코드 페이지, 한국어 Windows 는 CP949) 으로 보내면 // 한글 비밀번호가 틀렸다고 나왔다 (관리자 지시로 10/18 시험: UTF-8 + -sccUTF-8 이면 7z · RAR 이 한글 비밀번호로 풀림) pr.stdin.encoding = utf8; // 묻지 않을 때도 빈 줄을 보내 기다리지 않게 (암호가 걸렸는데 비밀번호가 없으면 틀림으로 끝난다) pr.stdin.writeln(password ?? ''); // 만들 때는 "다시 넣기" 도 묻는다 (zip_aes) if (confirm) pr.stdin.writeln(password ?? ''); await pr.stdin.close(); final out = StringBuffer(), err = StringBuffer(); // 끝을 기다리는 Future 는 바로 만든다 (프로세스가 끝난 뒤 asFuture 를 부르면 이미 끝난 흐름은 영영 끝나지 않았다) final a = pr.stdout.transform(const Utf8Decoder(allowMalformed: true)).forEach((s) { out.write(s); if (onLine != null) { for (final l in s.split(RegExp(r'[\r\n\b]+'))) { if (l.trim().isNotEmpty) onLine(l); } } }); final b = pr.stderr.transform(const Utf8Decoder(allowMalformed: true)).forEach(err.write); Timer? watch; if (cancelled != null) { watch = Timer.periodic(const Duration(milliseconds: 200), (_) { if (cancelled()) pr.kill(); }); } final code = await pr.exitCode; watch?.cancel(); await a.catchError((_) {}); await b.catchError((_) {}); if (cancelled?.call() ?? false) throw const ArchiveCancelled(); return (code, '$out', '$err'); } bool _wrongPassword(String text) { final t = text.toLowerCase(); return t.contains('wrong password') || t.contains('can not open encrypted') || t.contains('cannot open encrypted'); } /// 7z · RAR 목록 (머리까지 암호가 걸렸으면 [password] 없이 [ArchivePasswordException]) Future> list7z(String exe, String path, {String? password}) async { final (code, out, err) = await _run7z(exe, ['l', '-slt', '-ba', path], password: password); if (code != 0) { if (_wrongPassword('$out\n$err')) throw ArchivePasswordException(wrong: password != null && password.isNotEmpty); throw ProcessException(exe, const ['l'], err.trim().isEmpty ? out.trim() : err.trim(), code); } return parse7zList(out); } /// 7z · RAR 풀기 - [plan] 의 파일만 (목록 파일로 넘김, 링크 · 막은 것은 빠짐). 같은 이름은 [conflict] 대로 Future extract7zPlan(String exe, String path, String dest, ExtractPlan plan, {NameConflict conflict = NameConflict.rename, bool skipNewer = false, String? password, void Function(double v)? onProgress, bool Function()? cancelled}) async { final r = ExtractResult(); // 관리자 판단: 덮어쓰기 (-aoa) 에도 받는 쪽이 더 새 파일은 목록에서 빼서 그대로 둔다 (229) if (conflict == NameConflict.overwrite && skipNewer) { final keep = [for (final e in plan.files) if (_targetNewer(extractTarget(dest, e.name), e.modified)) e]; r.keptNewer.addAll(keep.map((e) => e.name)); plan = ExtractPlan([for (final e in plan.files) if (!keep.contains(e)) e], plan.unsafe, plan.links, plan.totalSize, plan.encrypted, plan.badNames); } if (plan.files.isEmpty) return r; final tmp = await Directory.systemTemp.createTemp('jj_7z_'); try { final list = File(p.join(tmp.path, 'list.txt')); await list.writeAsString(plan.files.map((e) => e.raw).join('\n'), encoding: utf8); final existing = [for (final e in plan.files) if (FileSystemEntity.typeSync(extractTarget(dest, e.name)) != FileSystemEntityType.notFound) e.name]; final ao = switch (conflict) { NameConflict.overwrite => '-aoa', NameConflict.skip => '-aos', _ => '-aou' }; final (code, out, err) = await _run7z(exe, ['x', path, '-o$dest', ao, '-bsp1', '-bb0', '@${list.path}'], password: password, cancelled: cancelled, onLine: (l) { final m = RegExp(r'(\d+)%').firstMatch(l); if (m != null) onProgress?.call(int.parse(m[1]!) / 100); }); if (code != 0) { if (_wrongPassword('$out\n$err')) throw ArchivePasswordException(wrong: password != null && password.isNotEmpty); throw ProcessException(exe, const ['x'], err.trim().isEmpty ? out.trim() : err.trim(), code); } if (conflict == NameConflict.skip) { r.skippedExisting.addAll(existing); } else if (conflict == NameConflict.rename) { r.renamed.addAll(existing); } r.done = plan.files.length - (conflict == NameConflict.skip ? existing.length : 0); return r; } finally { try { await tmp.delete(recursive: true); } catch (_) {} } }