JJ_MKVMaker/lib/core/sync/copy_commands.dart

239 lines
14 KiB
Dart

import 'package:path/path.dart' as p;
import '../files/path_rules.dart' show appTempRsyncExcludes;
import 'rsync_options.dart';
// 약속 (나누기 sync_tools): 복사 방법 (앱 · rsync · robocopy) 과 그 명령 인수 - rsync 인수 · Windows 경로를 MSYS2 꼴로 ·
// robocopy 실행 묶음 · 성공 종료 코드 · Windows 용 rsync 패키지 (MSYS2, SHA256 고정).
// 입력: 옵션 글 · 원본들 · 대상 · 속도 제한 · 이동인지. 출력: 인수 목록 (실행은 부르는 쪽). 오류: 던지지 않는다.
/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화)
///
/// - builtin: 앱이 직접 복사 ("현재 방식"). 모든 기기.
/// - rsync: rsync (Windows 는 MSYS2 의 rsync 를 처음 쓸 때 내려받음, Android 는 직접 지정한 실행 파일)
/// - robocopy: Windows 에 들어 있는 robocopy (Windows 만)
enum CopyMethod {
builtin('현재 방식'),
rsync('rsync'),
robocopy('robocopy');
final String label;
const CopyMethod(this.label);
static CopyMethod of(String? name) => values.firstWhere((m) => m.name == name, orElse: () => builtin);
}
const defaultRsyncOptions = '-avPog';
const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
/// 관리자 판단 (10/20, 28 확인): rsync (Windows MSYS2) 는 목록 · 기록 · 배치 파일의 C:\… · C:/… · \\서버\… 를 열지 못한다
/// ("failed to open exclude file", exit 11) - 그 값들을 경로처럼 [toCygwinPath] 로 바꿔 넘긴다 (막지 않음).
/// 대상: --exclude-from · --include-from · --files-from · --filter / -f 의 ". 파일" · "merge 파일" · --log-file · --write-batch · --read-batch · --only-write-batch
List<String> rsyncFileOptionArgs(List<String> words, {required bool windows}) {
if (!windows) return words;
const opts = {
'--exclude-from', '--include-from', '--files-from', '--log-file', '--write-batch', '--read-batch', '--only-write-batch',
};
// 28 · 15 확인 (10/20, MSYS 3.5.1): 이 파일들은 UNC 를 \\서버\… 로만 연다 (//서버/… 는 "failed to open", exit 11) - UNC 는 \\ 로,
// 드라이브는 /cygdrive/… 로
String conv(String v) => RegExp(r'^[A-Za-z]:[\\/]').hasMatch(v)
? toCygwinPath(v)
: RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(v)
? v.replaceAll('/', '\\')
: v;
String rule(String r) {
final m = RegExp(r'^(\s*(?:\.|merge)(?:,[^\s]*)?\s+)(.+)$').firstMatch(r);
return m == null ? r : '${m[1]}${conv(m[2]!)}';
}
final out = <String>[];
for (var i = 0; i < words.length; i++) {
final w = words[i];
final next = i + 1 < words.length ? words[i + 1] : null;
final eq = w.indexOf('=');
if (w.startsWith('--') && eq > 0 && opts.contains(w.substring(0, eq))) {
out.add('${w.substring(0, eq + 1)}${conv(w.substring(eq + 1))}');
} else if (opts.contains(w) && next != null) {
out
..add(w)
..add(conv(next));
i++;
} else if (w.startsWith('--filter=')) {
out.add('--filter=${rule(w.substring(9))}');
} else if (w == '--filter' && next != null) {
out
..add(w)
..add(rule(next));
i++;
} else if (!w.startsWith('--') && w.length > 1 && w.startsWith('-')) {
// 짧은 옵션 묶음의 f: 붙은 값 (-f". 목록") 또는 다음 낱말 (-avf ". 목록")
final k = w.indexOf(RegExp('[BefMT@]'), 1);
if (k >= 0 && w[k] == 'f' && k < w.length - 1) {
out.add('${w.substring(0, k + 1)}${rule(w.substring(k + 1))}');
} else if (k >= 0 && w[k] == 'f' && next != null) {
out
..add(w)
..add(rule(next));
i++;
} else {
out.add(w);
// 다른 값 받는 글자 (-e ssh 등) 의 값은 그대로 건너뜀
if (k == w.length - 1 && next != null) {
out.add(next);
i++;
}
}
} else {
out.add(w);
}
}
return out;
}
/// Windows 경로 → MSYS2 rsync 가 읽는 경로 (C:\a\b → /cygdrive/c/a/b, \\server\share\x → //server/share/x).
/// MSYS2 rsync 는 "C:" 를 원격 컴퓨터 이름으로 읽으므로 꼭 바꿔야 한다.
/// 받는 쪽 (대상) 경로를 rsync 에 넘길 꼴로. 28 · 15 확인 (10/20, MSYS 3.5.1): 받는 쪽의 UNC 는 \\서버\… 만 된다
/// (//서버/… 는 change_dir 실패, exit 3) - 원본은 거꾸로 //서버/… 만 되어 [toCygwinPath] 그대로
String rsyncDestPath(String path, {bool windows = true}) {
if (windows && RegExp(r'^(?:\\\\|//)(?!cygdrive/)').hasMatch(path)) return path.replaceAll('/', '\\');
return toCygwinPath(path, windows: windows);
}
String toCygwinPath(String path, {bool windows = true}) {
if (!windows) return path;
final s = path.replaceAll('\\', '/');
final m = RegExp(r'^([A-Za-z]):(/.*)?$').firstMatch(s);
if (m != null) return '/cygdrive/${m[1]!.toLowerCase()}${m[2] ?? '/'}';
return s; // //server/share 는 그대로 읽는다
}
/// rsync 옵션에 -u (--update: 받는 쪽이 더 새 파일은 건너뜀) 를 더한다. 이미 있으면 그대로 (-avu 처럼 묶인 것도).
/// 양쪽 (⇄) 으로 함께 맞출 때 서로의 새 파일을 옛 파일로 덮어쓰지 않게.
String withUpdateOption(String options) {
final has = splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u')));
return has ? options : '${options.trim()} -u'.trim();
}
/// rsync 인수: [options] + (대역폭 · 이동) + 원본들 + 대상 폴더/
/// 원본 폴더는 끝에 / 를 붙이지 않는다 (폴더째 대상 안으로 - 앱의 복사와 같은 결과).
List<String> rsyncArgs({
required String options,
required List<String> sources,
required String dest,
int bandwidthKBps = 0,
bool move = false,
bool windows = true,
bool contents = false,
}) {
// 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다
final opts = rsyncFileOptionArgs([for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o], windows: windows);
return [
...opts,
if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps',
// 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 출력 (Android 빌드는 iconv 없음 · 진행 표시에 쓰는 이름)
if (!windows && !opts.contains('-8') && !opts.contains('--8-bit-output')) '-8',
if (move) '--remove-source-files',
...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다
// [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양)
for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}',
'${rsyncDestPath(_noTrailingSlash(dest), windows: windows)}/',
];
}
String _noTrailingSlash(String s) => s.length > 3 && (s.endsWith('/') || s.endsWith('\\')) ? s.substring(0, s.length - 1) : s;
/// 원본을 지우는 rsync · robocopy 옵션인지 (--remove-source-files · --remove-sent-files · /MOV · /MOVE) - 107 · 178
bool isSourceRemovingOption(String o) =>
o == '--remove-source-files' || o == '--remove-sent-files' || o.toUpperCase() == '/MOV' || o.toUpperCase() == '/MOVE';
/// robocopy 한 번 실행할 인수 (robocopy 는 "폴더 → 폴더" 라 원본마다 따로 만든다).
/// - 폴더: robocopy <폴더> <대상>\<폴더 이름> [옵션]
/// - 파일: robocopy <파일이 있는 폴더> <대상> <파일 이름들…> [옵션] (같은 폴더의 파일은 한 번에)
/// 대역폭: robocopy 에는 속도 제한이 없어 /IPG (조각 사이 쉬는 시간) 로 비슷하게 맞춘다.
List<List<String>> robocopyRuns({
required String options,
required List<String> folders,
required List<String> files,
required String dest,
int bandwidthKBps = 0,
bool move = false,
bool contents = false,
bool skipNewer = false,
}) {
// 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
final extra = robocopyExtraOptions(opts, move: move, skipNewer: skipNewer, bandwidthKBps: bandwidthKBps);
final runs = <List<String>>[
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],
];
final byDir = <String, List<String>>{};
for (final f in files) {
byDir.putIfAbsent(p.dirname(f), () => []).add(p.basename(f));
}
// 파일만 옮길 때 /E · /MOVE 는 하위 폴더까지 건드리므로 뺀다
final fileOpts = opts.where((o) => !const ['/E', '/S', '/MIR'].contains(o.toUpperCase())).toList();
final fileExtra = extra.map((o) => o == '/MOVE' ? '/MOV' : o).toList();
byDir.forEach((dir, names) => runs.add([dir, dest, ...names, ...fileOpts, ...fileExtra]));
return runs;
}
/// 앱이 옵션 칸 ([opts], 원본 지우기 옵션은 뺀 것) 뒤에 붙이는 robocopy 옵션 - 실행 (robocopyRuns) 과 화면의 명령 줄이 같게
List<String> robocopyExtraOptions(List<String> opts, {bool move = false, bool skipNewer = false, int bandwidthKBps = 0}) => [
if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}',
if (move) ...robocopyMoveOptions(opts),
// 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동 모두 처음 값 - rsync -a 가 링크를 링크로 두는 것처럼).
// 따라가면 원본 바깥 폴더의 파일을 옮기고 지우거나 (/MOVE), 고리 junction (AppData 의 "Application Data" 등) 을 끝없이 돈다.
// 옵션 칸에 사용자가 링크 옵션을 넣었으면 그대로
if (!robocopyLinkOption(opts)) '/XJ',
// 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다
if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO',
];
/// 옵션 칸에 링크 · junction 을 다루는 robocopy 옵션이 있는지 (/XJ · /XJD · /XJF · /SJ · /SL)
bool robocopyLinkOption(List<String> opts) => opts.any((o) => const ['/XJ', '/XJD', '/XJF', '/SJ', '/SL'].contains(o.toUpperCase()));
/// robocopy 가 원본의 링크 · junction 을 건너뛰는지 (미리 보기) - 사용자가 /SJ · /SL 로 링크째 복사를 고르지 않았으면
bool robocopySkipsLinks(List<String> opts) => !opts.any((o) => const ['/SJ', '/SL'].contains(o.toUpperCase()));
/// 이동에 붙이는 robocopy 옵션: /MOVE + 같은 파일도 포함.
/// 217: 대상에 같은 파일이 있으면 robocopy 는 건너뛰고 원본에 남긴다 (종료 코드 0 - "옮겼습니다" 인데 남음, Windows 에서 확인).
/// 같음 (/IS) 뿐 아니라 속성만 다름 (Tweaked, /IT) · 만든 시각만 다름 (Modified, /IM - 다른 프로그램으로 복사한 같은 파일) 도 넣어 원본에서 지운다.
/// 그래도 남는 것은 이동 뒤 다시 세어 알린다 (216). 링크 · junction 은 [robocopyExtraOptions] 의 /XJ 로 남긴다
/// (robocopy 는 기본으로 junction 을 따라가 /MOVE 가 링크가 가리키는 원본 바깥 폴더의 파일까지 옮기고 지웠다 - Windows 에서 확인)
List<String> robocopyMoveOptions(List<String> opts) => [
'/MOVE',
for (final o in const ['/IS', '/IT', '/IM'])
if (!opts.any((x) => x.toUpperCase() == o)) o,
];
/// robocopy /IPG 밀리초: robocopy 는 64KB 조각마다 쉰다 → 64 / KB/s 초
int robocopyIpg(int kbps) => kbps <= 0 ? 0 : (64 * 1000 / kbps).round().clamp(1, 60000);
/// rsync · robocopy 의 성공 종료 코드
bool rsyncOk(int code) => code == 0 || code == 24; // 24: 복사 도중 원본 파일이 사라짐
bool robocopyOk(int code) => code >= 0 && code < 8;
/// Windows 용 rsync: MSYS2 공식 저장소의 패키지 (처음 쓸 때 내려받음, 파일 SHA256 을 고정해 검증)
const msys2Repo = 'https://repo.msys2.org/msys/x86_64/';
const msys2RsyncPackages = <(String, String)>[
('rsync-3.5.1-2-x86_64.pkg.tar.zst', 'cba52e41d16f9873324fcf09853619964da87bc1ce116781a5449c968d4ffecc'),
('msys2-runtime-3.6.10-6-x86_64.pkg.tar.zst', 'b2db5bae3826f15cacb536f4c7c6c7e31d3fabec00aac8f127473aba7e994d22'),
('gcc-libs-15.3.0-1-x86_64.pkg.tar.zst', '0d99a122c453c05ae21ba3dcea910f2e0d93d38ae067c677a112a315b0f3cec5'),
('libiconv-1.19-1-x86_64.pkg.tar.zst', '0fa55ea2a6ccf97cf8c58b24b2615815e15e16e6e4e888091c263c2c83c5313d'),
('libidn2-2.3.8-1-x86_64.pkg.tar.zst', '55e0de96b893e9f67f14a4283d411fc2a45d7b59333bc16e2a9b16e5bfc9282f'),
('libintl-0.22.5-1-x86_64.pkg.tar.zst', '336d66b9d95cf9c1804958f8e260762a3e83bf158ed5981f783bc772a31073cf'),
('liblz4-1.10.0-1-x86_64.pkg.tar.zst', '1cbbe51ee91bf32691aabb15bdb7c8548e8b5ef8195122ef31f10edcca6e952f'),
('libopenssl-3.6.5-1-x86_64.pkg.tar.zst', '05f6cc946f0dde3aecc3632007fa1d874e40720226117b750327bc2a01846325'),
('libunistring-1.4.2-1-x86_64.pkg.tar.zst', 'bcd04706d95b5e7127d39834565bceed6b400192f5fb51599745aba28d5da018'),
('libxxhash-0.8.4-1-x86_64.pkg.tar.zst', 'ba784ad117626401cd5d3ec0cf70399f9de72a63755f96c28fa2032303f9c330'),
('libzstd-1.5.7-1-x86_64.pkg.tar.zst', '5e370b2e725a0a7e5ff58d84653c9a0cd5a10b9cfe930f2d949a28ad2a11c1f2'),
('popt-1.19-1-x86_64.pkg.tar.zst', 'b402bd1d03815c5e28b7ab415d8e20f487eff2460ae0271a99674eeb0d77194f'),
];
/// 패키지에서 꺼낼 파일 (rsync.exe 와 필요한 DLL 만)
const msys2RsyncFiles = {
'rsync.exe', 'msys-2.0.dll', 'msys-gcc_s-seh-1.dll', 'msys-iconv-2.dll', 'msys-idn2-0.dll', 'msys-intl-8.dll',
'msys-lz4-1.dll', 'msys-crypto-3.dll', 'msys-ssl-3.dll', 'msys-unistring-5.dll', 'msys-xxhash-0.dll',
'msys-zstd-1.dll', 'msys-popt-0.dll', 'msys-charset-1.dll',
};