next9: + msys_args (7160577: Windows rsync 인수를 응답 파일로 · MSYS=noglob - ' 가 인수를 합쳐 거르기가 사라지던 것) - 시험판 985

합침 (d3): unreadableOptions 는 optionProblems 를 거친다 (b1a43c3 의 종류별 안내). 15 의 windows 매개변수 · 큰따옴표 막기를
optionProblems 로 옮기고, 안내는 새 종류 doubleQuote "큰따옴표 (\") 를 빼 주세요" (내 판단).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next9
RyuJongJin 2026-10-20 16:59:12 +09:00
commit bd0801ce66
8 changed files with 224 additions and 6 deletions

View File

@ -2601,6 +2601,9 @@
"큰 파일": "Large files",
"큰 파일이 없습니다": "No large files",
"큰 폴더": "Large folders",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "Options containing a double quote (\") cannot be passed to rsync on Windows: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "Arguments containing a double quote (\") cannot be passed as-is to rsync on Windows, so it was not run: {0}",
"큰따옴표 (\") 를 빼 주세요": "Remove the double quote (\")",
"키 있음": "Key saved",
"타갈로그어": "Tagalog",
"타밀어": "Tamil",

View File

@ -2601,6 +2601,9 @@
"큰 파일": "大きいファイル",
"큰 파일이 없습니다": "大きいファイルはありません",
"큰 폴더": "大きいフォルダー",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "ダブルクォート (\") を含むオプションは Windows の rsync に渡せません: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "ダブルクォート (\") を含む引数は Windows の rsync にそのまま渡せないため、実行しませんでした: {0}",
"큰따옴표 (\") 를 빼 주세요": "二重引用符 (\") を外してください",
"키 있음": "キーあり",
"타갈로그어": "タガログ語",
"타밀어": "タミル語",

View File

@ -2601,6 +2601,9 @@
"큰 파일": "大文件",
"큰 파일이 없습니다": "没有大文件",
"큰 폴더": "大文件夹",
"큰따옴표 (\") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}": "含有双引号 (\") 的选项无法传给 Windows 上的 rsync: {0}",
"큰따옴표 (\") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}": "含有双引号 (\") 的参数无法原样传给 Windows 上的 rsync,因此未执行: {0}",
"큰따옴표 (\") 를 빼 주세요": "请去掉双引号 (\")",
"키 있음": "已有密钥",
"타갈로그어": "他加禄语",
"타밀어": "泰米尔语",

View File

@ -17,6 +17,7 @@ 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 로 준비해 빌드에 넣음)
@ -630,7 +631,15 @@ class LiveSync extends ChangeNotifier {
if (exe == null) throw StateError(tr('rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)'));
final args = rsyncArgsFor(x, s, windows: Platform.isWindows);
await Directory(x.target).create(recursive: true);
final r = await Process.run(exe, args, stdoutEncoding: utf8, stderrEncoding: utf8);
// 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;
}

View File

@ -11,6 +11,7 @@ import '../core/sync_tools.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart';
import '../core/rsync_filter.dart';
import '../core/rsync_launch.dart';
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
@ -401,8 +402,20 @@ class TransferJob extends ChangeNotifier {
if (log.length > 20) log.removeAt(0);
}
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut) async {
_proc = await Process.start(exe, args, runInShell: false);
/// [rsync]: Windows 의 rsync 는 응답 파일 · MSYS=noglob 으로 띄운다 (P0 10/20 - ' · * 를 런타임이 다시 읽지 않게, [prepareRsyncLaunch]).
/// robocopy 는 Windows 프로그램이라 그대로
Future<int> _start(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut, {bool rsync = false}) async {
final launch = rsync ? await prepareRsyncLaunch(args) : null;
try {
return await _startRaw(exe, launch?.args ?? args, enc, onOut, workingDirectory: launch?.workingDirectory, environment: launch?.environment);
} finally {
launch?.dispose();
}
}
Future<int> _startRaw(String exe, List<String> args, Encoding enc, void Function(String chunk) onOut,
{String? workingDirectory, Map<String, String>? environment}) async {
_proc = await Process.start(exe, args, runInShell: false, workingDirectory: workingDirectory, environment: environment);
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]'))) {
@ -459,6 +472,7 @@ class TransferJob extends ChangeNotifier {
}
_tick();
},
rsync: true,
);
if (cancelled) return;
if (!rsyncOk(code)) throw ProcessException(exe, const [], rsyncFailureText(code, errLog, log), code);

View File

@ -0,0 +1,44 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import '../l10n/tr.dart';
/// 관리자 지시 (10/20, 28 재현 - P0 데이터): Windows 의 rsync (MSYS2 · Cygwin 런타임) 는 Windows 프로그램이 넘긴 명령 줄을
/// 스스로 다시 읽는다 - ' 를 따옴표로 짝지어 (--exclude=it's.txt --exclude=Bob's.txt 가 한 인수로 합쳐져 거르기가 사라지고
/// 지킬 파일이 지워짐, ' 가 든 경로는 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼친다 (--exclude *.log 가 x.log y.log 로).
/// 앱이 만든 인수를 응답 파일 (@파일) 에 큰따옴표로 감싸 넣고 MSYS=noglob 으로 띄우면 그대로 간다 (실제 rsync 3.5.1 로 확인 -
/// MSYS=noglob 만으로는 ' 를 막지 못하고, CYGWIN=noglob 은 소용없다).
/// 응답 파일의 경로에 빈칸 · ' 가 있으면 @ 가 먹히지 않아, 그 파일이 든 폴더를 작업 폴더로 하고 "@args.txt" 로 넘긴다.
/// 큰따옴표 (") 가 든 인수는 이 방법으로도 그대로 넘기지 못한다 - 실행하지 않고 이유를 알린다 (Windows 파일 이름에는 " 가 없다).
class RsyncLaunch {
final List<String> args;
final String? workingDirectory;
final Map<String, String>? environment;
final Directory? _dir;
RsyncLaunch._(this.args, this.workingDirectory, this.environment, this._dir);
/// 다 쓴 응답 파일을 지운다 (rsync 가 끝난 뒤)
void dispose() {
try {
_dir?.deleteSync(recursive: true);
} catch (_) {}
}
}
/// Windows rsync 에 그대로 넘길 수 없는 인수 (큰따옴표가 듦)
List<String> msysUnsafeArgs(List<String> args) => [for (final a in args) if (a.contains('"')) a];
/// [args] 를 rsync 에 그대로 넘기도록 준비한다 (Windows 만 응답 파일 · MSYS=noglob, 그 밖은 그대로). 끝나면 [RsyncLaunch.dispose]
Future<RsyncLaunch> prepareRsyncLaunch(List<String> args, {bool? windows}) async {
if (!(windows ?? Platform.isWindows)) return RsyncLaunch._(args, null, null, null);
final bad = msysUnsafeArgs(args);
if (bad.isNotEmpty) {
throw StateError(trf('큰따옴표 (") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}', [bad.join(' · ')]));
}
final dir = await Directory.systemTemp.createTemp('jj_rsargs_');
await File(p.join(dir.path, 'args.txt')).writeAsString(args.map((a) => '"$a"').join(' '), encoding: utf8);
final msys = Platform.environment['MSYS'];
return RsyncLaunch._(['@args.txt'], dir.path, {'MSYS': msys == null || msys.trim().isEmpty ? 'noglob' : '$msys noglob'}, dir);
}

View File

@ -1,3 +1,5 @@
import 'dart:io' show Platform;
import 'package:path/path.dart' as p;
import 'file_ops.dart' show appTempRsyncExcludes;
@ -198,10 +200,10 @@ const _rsyncLongWithValue = {
/// 관리자 지시 (10/20, 28 · 15 확인): [splitOptions] 로 고쳐 읽은 뒤에도 rsync 가 다르게 읽을 것 - 있으면 확인 창 · 실시간 동기화에
/// 빨갛게 알리고 실행을 막는다: 닫히지 않은 따옴표, 옵션 자리의 - 로 시작하지 않는 낱말 (rsync 는 원본 경로로 읽어 옵션이 몰래 빠짐),
/// 하이픈 셋 이상, 끝에 값 없이 남은 값 받는 옵션. 옵션의 값 자리 (--exclude 한글.txt 의 한글.txt, -f 뒤) 는 세지 않는다
List<String> unreadableOptions(String options) => [for (final x in optionProblems(options)) x.text];
List<String> unreadableOptions(String options, {bool? windows}) => [for (final x in optionProblems(options, windows: windows)) x.text];
/// 옵션 칸의 문제 종류 - 안내의 끝 문구 (고칠 방법) 를 고른다 (관리자 지시 10/20, 28 지적)
enum OptionProblemKind { unclosed, path, noValue, odd }
enum OptionProblemKind { unclosed, path, noValue, odd, doubleQuote }
/// [unreadableOptions] 의 한 줄과 그 종류
class OptionProblem {
@ -224,6 +226,7 @@ String optionProblemHint(Iterable<OptionProblem> problems) {
if (kinds.contains(OptionProblemKind.path)) tr('옵션은 - 나 -- 로 시작합니다'),
if (kinds.contains(OptionProblemKind.noValue)) tr('값을 적어 주세요 (예: --exclude=*.tmp)'),
if (kinds.contains(OptionProblemKind.odd)) tr('곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요'),
if (kinds.contains(OptionProblemKind.doubleQuote)) tr('큰따옴표 (") 를 빼 주세요'),
].join(' · ');
}
@ -232,13 +235,21 @@ String optionProblemMessage(List<OptionProblem> problems) =>
trf('옵션을 알아볼 수 없습니다: {0} - {1}', [problems.join(' · '), optionProblemHint(problems)]);
/// [unreadableOptions] 의 몸통 (종류와 함께)
List<OptionProblem> optionProblems(String options) {
List<OptionProblem> optionProblems(String options, {bool? windows}) {
windows ??= Platform.isWindows;
final parsed = _parseOptions(options);
// 15 확인 F1: 닫히지 않은 따옴표 - 뒤의 옵션이 다르게 읽힐 수 있다
final bad = [for (final u in parsed.unclosed) OptionProblem(OptionProblemKind.unclosed, trf('따옴표가 닫히지 않았습니다: {0}', [u]), u)];
var value = false;
var last = '';
for (final t in parsed.words) {
// P0 (10/20): Windows 의 rsync (MSYS2) 에는 큰따옴표가 든 인수를 그대로 넘길 수 없다 (prepareRsyncLaunch) - 값 자리도.
// 닫히지 않은 따옴표로 이미 막혔으면 (그 따옴표가 낱말에 남음) 한 번만 알린다
if (windows && parsed.unclosed.isEmpty && t.contains('"')) {
bad.add(OptionProblem(OptionProblemKind.doubleQuote, trf('큰따옴표 (") 가 든 옵션은 Windows 의 rsync 에 넘길 수 없습니다: {0}', [t]), t));
value = false;
continue;
}
if (value) {
value = false;
continue;

View File

@ -0,0 +1,131 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/app/transfer_job.dart';
import 'package:jj_mkvmaker/core/rsync_launch.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:path/path.dart' as p;
import 'support/rsync_scenes.dart' show sceneRsync, treeOf;
/// P0 (관리자 지시 10/20, 28 재현 - 데이터): Windows 의 rsync (MSYS2 런타임) 가 명령 줄을 다시 읽어 ' 를 짝짓고 (거르기가 사라져
/// 지킬 파일이 지워짐 · ' 경로가 합쳐져 실패) * ? [ ] { } 를 작업 폴더의 파일로 펼치던 것 - 응답 파일 + MSYS=noglob 으로 그대로 넘긴다.
/// 앱이 rsync 를 띄우는 두 길 (복사 · Rsync 화면 · 모니터링 · 최종 정리 = TransferJob, 실시간 동기화 = LiveSync) 을 실제 rsync 로.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('도우미: Windows 는 응답 파일 · MSYS=noglob, 그 밖은 그대로 · 큰따옴표가 든 인수는 막음', () async {
final plain = await prepareRsyncLaunch(['-a', "it's"], windows: false);
expect(plain.args, ['-a', "it's"]);
expect(plain.environment, isNull);
final w = await prepareRsyncLaunch(['-a', "--exclude=it's.txt", 'a b'], windows: true);
addTearDown(w.dispose);
expect(w.args, ['@args.txt']);
expect(w.environment!['MSYS'], contains('noglob'));
expect(File(p.join(w.workingDirectory!, 'args.txt')).readAsStringSync(), '"-a" "--exclude=it\'s.txt" "a b"');
await expectLater(prepareRsyncLaunch(['--exclude=a"b'], windows: true), throwsA(isA<StateError>()));
// 옵션 칸에서도 미리 빨갛게 (Windows 만)
expect(unreadableOptions('-a --exclude=\'a"b\'', windows: true), hasLength(1));
expect(unreadableOptions('-a --exclude=\'a"b\'', windows: false), isEmpty);
expect(unreadableOptions('-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"', windows: true), isEmpty);
});
group('실제 rsync', () {
final rsync = sceneRsync;
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_msysargs_'));
tearDown(() => tmp.deleteSync(recursive: true));
/// 원본 · 대상 폴더를 만들고 파일을 넣는다 (내용 = 쪽:이름)
(String, String) scene(String sn, String dn, List<String> sf, List<String> df) {
final s = Directory(p.join(tmp.path, sn))..createSync(recursive: true);
final d = Directory(p.join(tmp.path, dn))..createSync(recursive: true);
for (final f in sf) {
File(p.join(s.path, f))
..parent.createSync(recursive: true)
..writeAsStringSync('s:$f');
}
for (final f in df) {
File(p.join(d.path, f))
..parent.createSync(recursive: true)
..writeAsStringSync('d:$f');
}
return (s.path, d.path);
}
// (이름, 옵션, 원본 폴더, 대상 폴더, 원본 파일, 대상 파일, 남아야 할 것 (대상), 없어야 할 것 (대상))
final cases = <(String, String, String, String, List<String>, List<String>, List<String>, List<String>)>[
("28 재현: ' 두 개의 --exclude", '-a --delete --exclude="it\'s.txt" --exclude="Bob\'s.txt"', 's', 'd', ['a.txt'],
["it's.txt", "Bob's.txt", 'old.txt'], ["it's.txt", "Bob's.txt", 'a.txt'], ['old.txt']),
("' 하나의 --exclude", "-a --delete --exclude=\"it's.txt\"", 's', 'd', ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'], ['old.txt']),
("' 가 든 원본 경로", '-a --delete', "s'1", 'd', ["a'b.txt"], ['old.txt'], ["a'b.txt"], ['old.txt']),
("' 가 든 원본 · 대상 경로", '-a --delete', "s'1", "d'2", ['a.txt', "c'd/e'f.txt"], ['old.txt'], ['a.txt', "c'd/e'f.txt"], ['old.txt']),
("' 경로 + ' --exclude", "-a --delete --exclude=\"it's.txt\"", "s'1", "d'2", ['a.txt'], ["it's.txt", 'old.txt'], ["it's.txt", 'a.txt'],
['old.txt']),
('따로 쓴 * (작업 폴더에 맞는 파일이 있어도)', '-a --delete --exclude *.txt', 's', 'd', ['a.txt', 'n.log'], ['keep.txt', 'old.log'],
['keep.txt', 'n.log'], ['old.log', 'a.txt']),
('따로 쓴 ? · [ ] · { }', '-a --delete --exclude b?.txt --exclude [x]* --exclude {q,r}.txt', 's', 'd', ['a.txt'],
['b9.txt', 'xk.txt', '{q,r}.txt', 'q.txt', 'old.txt'], ['b9.txt', 'xk.txt', '{q,r}.txt', 'a.txt'], ['q.txt', 'old.txt']),
('[ ] · { } · 빈칸 · 한글이 든 경로', '-a --delete', '원본 [1] {a}', "받는 쪽 [2] {b}'s", ['가 나.txt'], ['old.txt'], ['가 나.txt'], ['old.txt']),
];
for (final (name, opts, sn, dn, sf, df, keep, gone) in cases) {
test('복사 작업 (TransferJob): $name', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene(sn, dn, sf, df);
// 작업 폴더에 글로브에 맞을 파일을 둔다 (예전에는 따로 쓴 * 가 이것들로 펼쳐졌다)
final cwd = Directory.current;
final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: opts, rsyncExe: rsync, contents: true);
await job.run();
expect(job.error, isNull, reason: '${job.error}');
final t = treeOf(d);
for (final k in keep) {
expect(t.containsKey(k), isTrue, reason: '$name: $k 가 있어야 함 - $t');
}
for (final g in gone) {
expect(t.containsKey(g), isFalse, reason: '$name: $g 는 없어야 함 - $t');
}
expect(Directory.current.path, cwd.path);
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
}
test("실시간 동기화 (LiveSync rsync 쌍): ' 두 개의 --exclude · ' 가 든 경로", () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"'
..liveSyncPairs = [LiveSyncPair(s, d, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"')];
final live = LiveSync(c);
addTearDown(live.dispose);
final pair = c.settings.liveSyncPairs.single;
await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
final t = treeOf(d);
expect(t.keys.toSet(), {"it's.txt", "Bob's.txt", 'a.txt'}, reason: '거르기를 지키고 old.txt 만 지움');
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
test('응답 파일은 끝나면 지운다', () async {
if (rsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
final (s, d) = scene('s', 'd', ['a.txt'], []);
final job = TransferJob(sources: [s], dest: d, move: false, method: CopyMethod.rsync, options: '-a', rsyncExe: rsync, contents: true);
await job.run();
expect(job.error, isNull);
// 이 작업의 응답 파일 (이 시험의 경로가 든 것) 이 남지 않았는지 - 함께 도는 다른 시험의 것은 세지 않는다
final mine = [
for (final e in Directory.systemTemp.listSync())
if (p.basename(e.path).startsWith('jj_rsargs_') && File(p.join(e.path, 'args.txt')).existsSync())
if (File(p.join(e.path, 'args.txt')).readAsStringSync().contains(toCygwinPath(s))) e.path,
];
expect(mine, isEmpty);
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
});
}