설정 A 분야 4: 복사 · 이동 · rsync · 실시간 동기화 (CopySettings) - 키 16 · 도우미 _method 함께

- 부르는 곳: s.copyTasks → s.copy.tasks 꼴 (RsyncRun 의 자기 필드 copySkipNewer 는 그대로 - 같은 이름이라 잘못 바뀐 것을 되돌림)
- 문서 주석 [AppSettings.x] → [CopySettings.x] 등

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-22 23:10:42 +09:00
parent ee7524e1c7
commit 059c8cba59
55 changed files with 430 additions and 393 deletions

View File

@ -24,9 +24,9 @@ void main() {
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..copyMethodFolder = 'rsync'
..copyBandwidthKBps = 5000
..liveSyncPairs = [
..copy.methodFolder = 'rsync'
..copy.bandwidthKBps = 5000
..copy.liveSyncPairs = [
LiveSyncPair(r'D:\영상\원본', r'\\nas\backup\영상', method: 'rsync', delete: true),
LiveSyncPair(r'C:\Users\me\Documents', r'E:\Documents', enabled: false),
];

View File

@ -33,7 +33,7 @@ void main() {
}
Directory(dst).createSync();
final c = AppController(PlatformServices.create());
c.settings.liveSyncPairs = [
c.settings.copy.liveSyncPairs = [
LiveSyncPair(src, p.join(dst, 'mirror'), schedule: const ['0 9-17 * * 1-5', '0 22-23 * * 0,6'], delete: true),
];
final live = LiveSync(c)..start();

View File

@ -34,7 +34,7 @@ void main() {
final c = AppController(PlatformServices.create());
c.settings
..rsyncPaths = [tmp.path, tmp.path]
..copy.rsyncPaths = [tmp.path, tmp.path]
..explorer.style = 'windows';
final key = GlobalKey();
tester.view.physicalSize = const Size(1500, 900);
@ -101,8 +101,8 @@ void main() {
// 모니터링: 실행한 두 방향이 남는다
await tap(find.text('모니터링'));
await wait(1500);
expect(c.settings.copyTasks.length, 2);
expect(c.settings.copyTasks.every((t) => t.method == 'rsync' && t.contents && t.lastResult == 'done'), isTrue);
expect(c.settings.copy.tasks.length, 2);
expect(c.settings.copy.tasks.every((t) => t.method == 'rsync' && t.contents && t.lastResult == 'done'), isTrue);
await shot('rsync_monitor');
tester.view.reset();
tmp.deleteSync(recursive: true);
@ -124,8 +124,8 @@ void main() {
await tester.runAsync(findBundledRsync);
final c = AppController(PlatformServices.create());
c.settings
..rsyncPaths = [tmp.path, tmp.path]
..rsyncOptions = '-avPog --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/';
..copy.rsyncPaths = [tmp.path, tmp.path]
..copy.rsyncOptions = '-avPog --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/';
final key = GlobalKey();
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
@ -194,8 +194,8 @@ void main() {
await tester.runAsync(findBundledRsync);
final c = AppController(PlatformServices.create());
c.settings
..rsyncPaths = [tmp.path, tmp.path]
..liveSyncPairs = [LiveSyncPair(cDir, d, method: 'rsync', delete: true)];
..copy.rsyncPaths = [tmp.path, tmp.path]
..copy.liveSyncPairs = [LiveSyncPair(cDir, d, method: 'rsync', delete: true)];
final key = GlobalKey();
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
@ -240,7 +240,7 @@ void main() {
expect(File(p.join(b, 'sub', 'deep', 'x.txt')).readAsStringSync(), 'x');
expect(Directory(p.join(b, 'empty')).existsSync(), isTrue); // 빈 폴더도 대상에는 만들어짐 (rsync -a)
expect(Directory(a).existsSync(), isFalse, reason: '원본 폴더까지 지움');
final moved = c.settings.copyTasks.single;
final moved = c.settings.copy.tasks.single;
expect([moved.move, moved.prune, moved.lastResult], [true, 'all', 'done']);
expect(moved.lastMessage, '빈 폴더 4개 지움'); // sub/deep · sub · empty · srcA
@ -263,8 +263,8 @@ void main() {
expect(File(p.join(d, 'sub', 'deep', 'y.txt')).readAsStringSync(), 'y');
expect(Directory(cDir).existsSync(), isTrue, reason: '원본 폴더는 남김');
expect(Directory(cDir).listSync(), isEmpty, reason: '빈 폴더는 모두 지움');
expect(c.settings.liveSyncPairs.single.enabled, isFalse, reason: '원본이 비었으므로 실시간 동기화는 끔');
final fin = c.settings.copyTasks.firstWhere((t) => t.sources.single == cDir);
expect(c.settings.copy.liveSyncPairs.single.enabled, isFalse, reason: '원본이 비었으므로 실시간 동기화는 끔');
final fin = c.settings.copy.tasks.firstWhere((t) => t.sources.single == cDir);
expect([fin.options, fin.move, fin.prune, fin.lastResult], ['-avHPOg', true, 'keep', 'done']);
expect(fin.lastMessage, '빈 폴더 3개 지움'); // sub/deep · sub · empty (liveC 는 남김)
tester.view.reset();

View File

@ -149,7 +149,7 @@ void main() {
testWidgets('52: 복사 중에 다른 화면에 갔다 와도 진행 막대', (t) async {
// 다른 화면에 갔다 와도 아직 복사 중이게 (약 25초)
File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(2 * 1024 * 1024, 7));
c.settings.copyBandwidthKBps = 80;
c.settings.copy.bandwidthKBps = 80;
await show(t, ExplorerPage(device: device, c: c));
await selectAndPress(t, 'big.bin', '복사');
await t.tap(find.widgetWithText(FilledButton, '복사'));

View File

@ -60,9 +60,9 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorer.paths = [tmp.path, tmp.path]
..rsyncPaths = [tmp.path, tmp.path]
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];
..copy.rsyncPaths = [tmp.path, tmp.path]
..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];
c.videos.add(VideoItem(video));
// 256: 폰 가로에서 작업 기록을 켜도 목록이 여러 줄 보이는지 - 동영상 5개 · 기록 몇 줄
for (var i = 2; i <= 5; i++) {

View File

@ -51,12 +51,12 @@ void main() {
}
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
..copy.rsyncSource = 'custom'
..copy.rsyncPath = rsync
..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
// 이미 --exclude 만으로 확인한 쌍에 --delete-excluded 를 더함 → 다시 묻는 창 (주황 이유 줄 · 거른 것 묶음)
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp');
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
await t.runAsync(() => live.syncNow(pair));

View File

@ -47,13 +47,13 @@ void main() {
File(p.join(tmp.path, 'rules.txt')).writeAsStringSync('H a.txt\n');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-a --filter="merge ${toCygwinPath(p.join(tmp.path, 'rules.txt'))}"'
..liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)];
..copy.rsyncSource = 'custom'
..copy.rsyncPath = rsync
..copy.rsyncOptions = '-a --filter="merge ${toCygwinPath(p.join(tmp.path, 'rules.txt'))}"'
..copy.liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)];
final live = LiveSync(c)..start();
addTearDown(live.dispose);
final pair = c.settings.liveSyncPairs.single;
final pair = c.settings.copy.liveSyncPairs.single;
await t.runAsync(() => live.syncNow(pair));
expect(live.noAutoDelete[LiveSync.keyOf(pair)], isNotEmpty);
final shotKey = GlobalKey();

View File

@ -48,7 +48,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorer.paths = [left, right]
..copyConflict = 'overwrite';
..copy.conflict = 'overwrite';
final root = p.rootPrefix(tmp.path);
device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
final shotKey = GlobalKey();

View File

@ -51,7 +51,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorer.orientation = 'side'
..rsyncPaths = [l, r];
..copy.rsyncPaths = [l, r];
final shotKey = GlobalKey();
await t.pumpWidget(MaterialApp(
theme: buildTheme(),

View File

@ -28,7 +28,7 @@ class CopyCenter extends ChangeNotifier {
/// 작업 id → 지금 (또는 마지막) 실행
final jobs = <String, TransferJob>{};
List<CopyTask> get tasks => c.settings.copyTasks;
List<CopyTask> get tasks => c.settings.copy.tasks;
bool isRunning(String id) => jobs[id] != null && !jobs[id]!.finished;
@ -50,7 +50,7 @@ class CopyCenter extends ChangeNotifier {
CopyTask fresh(List<String> 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));
var m = CopyMethod.of(method ?? (hasDir ? s.copy.methodFolder : s.copy.methodFile));
if (!copyMethodAvailable(m, s)) m = CopyMethod.builtin;
// WebDAV 가 끼면 실제로는 앱이 직접 복사한다 (확인 창에 robocopy 로 잘못 보이지 않게). Rsync 화면 ([method] 지정) 은 그대로
if (method == null && [...sources, dest].any(isDav)) m = CopyMethod.builtin;
@ -61,9 +61,9 @@ class CopyCenter extends ChangeNotifier {
move: move,
contents: contents,
method: m.name,
options: m == CopyMethod.robocopy ? s.robocopyOptions : s.rsyncOptions,
once: s.copyRunMode == 'once',
bandwidthKBps: s.copyBandwidthKBps,
options: m == CopyMethod.robocopy ? s.copy.robocopyOptions : s.copy.rsyncOptions,
once: s.copy.runMode == 'once',
bandwidthKBps: s.copy.bandwidthKBps,
);
}
@ -79,20 +79,20 @@ class CopyCenter extends ChangeNotifier {
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]);
await c.updateSettings((x) => x.copy.tasks = [t, ...x.copy.tasks]);
notifyListeners();
return t;
}
Future<void> update(CopyTask t) async {
await c.updateSettings((x) => x.copyTasks = [for (final y in x.copyTasks) y.id == t.id ? t : y]);
await c.updateSettings((x) => x.copy.tasks = [for (final y in x.copy.tasks) y.id == t.id ? t : y]);
notifyListeners();
}
Future<void> 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]);
await c.updateSettings((x) => x.copy.tasks = [for (final y in x.copy.tasks) if (y.id != id) y]);
notifyListeners();
}
@ -126,7 +126,7 @@ class CopyCenter extends ChangeNotifier {
contents: t.contents,
prune: t.move ? t.prune : '',
conflict: conflict,
skipNewer: c.settings.copySkipNewer,
skipNewer: c.settings.copy.skipNewer,
);
jobs[t.id] = job;
job.addListener(notifyListeners);
@ -170,13 +170,13 @@ class CopyCenter extends ChangeNotifier {
];
final rest = t.sources.where((s) => !dirs.contains(s)).toList();
await c.updateSettings((x) => x
..liveSyncPairs = [
...x.liveSyncPairs,
..copy.liveSyncPairs = [
...x.copy.liveSyncPairs,
for (final n in pairs)
if (!x.liveSyncPairs.any((o) => o.source == n.source && o.target == n.target)) n,
if (!x.copy.liveSyncPairs.any((o) => o.source == n.source && o.target == n.target)) n,
]
..copyTasks = [
for (final y in x.copyTasks)
..copy.tasks = [
for (final y in x.copy.tasks)
if (y.id != t.id)
y
else if (rest.isNotEmpty)
@ -203,8 +203,8 @@ class CopyCenter extends ChangeNotifier {
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]);
..copy.liveSyncPairs = [for (final o in y.copy.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o]
..copy.tasks = [t, ...y.copy.tasks]);
notifyListeners();
return t;
}

View File

@ -41,8 +41,8 @@ Future<void> findBundledRsync() async {
/// - 직접 지정: 그 파일
/// - 기본: 앱에 들어 있는 것, 없으면 (Windows) 내려받아 둔 것
Future<String?> rsyncExecutable(AppSettings s) async {
if (s.rsyncSource == 'custom') {
return s.rsyncPath.isNotEmpty && File(s.rsyncPath).existsSync() ? s.rsyncPath : null;
if (s.copy.rsyncSource == 'custom') {
return s.copy.rsyncPath.isNotEmpty && File(s.copy.rsyncPath).existsSync() ? s.copy.rsyncPath : null;
}
final b = bundledRsync;
if (b != null && File(b).existsSync()) return b;
@ -54,11 +54,11 @@ Future<String?> rsyncExecutable(AppSettings s) async {
bool copyMethodAvailable(CopyMethod m, AppSettings s) => switch (m) {
CopyMethod.builtin => true,
CopyMethod.robocopy => Platform.isWindows,
CopyMethod.rsync => Platform.isWindows || s.rsyncSource == 'custom' || bundledRsync != null,
CopyMethod.rsync => Platform.isWindows || s.copy.rsyncSource == 'custom' || bundledRsync != null,
};
/// 실시간 동기화 (lsyncd 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다.
/// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [AppSettings.liveSyncIntervalSec] 마다 살핀다.
/// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [CopySettings.liveSyncIntervalSec] 마다 살핀다.
/// 앱이 켜져 있는 동안만 동작하고, 시작할 때 한 번 맞춘다.
class LiveSync extends ChangeNotifier {
final AppController c;
@ -82,13 +82,13 @@ class LiveSync extends ChangeNotifier {
/// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다
Timer? _clock;
/// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [AppSettings.liveSyncOnStart] 를 따른다)
/// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [CopySettings.liveSyncOnStart] 를 따른다)
final paused = <String>{};
/// [hold] 면 켜진 쌍을 모두 멈춘 채로 시작 (모니터링 · [resume] 으로 시작)
void start({bool hold = false}) {
instance = this;
if (hold) paused.addAll(c.settings.liveSyncPairs.where((x) => x.enabled).map(keyOf));
if (hold) paused.addAll(c.settings.copy.liveSyncPairs.where((x) => x.enabled).map(keyOf));
c.addListener(_apply);
_apply();
_clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _onClock());
@ -120,19 +120,19 @@ class LiveSync extends ChangeNotifier {
/// 지금 지켜보는 쌍 (켜져 있고 멈추지 않은 것)
List<LiveSyncPair> get watching => [
for (final x in c.settings.liveSyncPairs)
for (final x in c.settings.copy.liveSyncPairs)
if (x.enabled && !paused.contains(keyOf(x))) x,
];
void pause(LiveSyncPair x) => _setPaused({...paused, keyOf(x)});
void resume(LiveSyncPair x) => _setPaused({...paused}..remove(keyOf(x)));
void pauseAll() => _setPaused({...paused, ...c.settings.liveSyncPairs.map(keyOf)});
void pauseAll() => _setPaused({...paused, ...c.settings.copy.liveSyncPairs.map(keyOf)});
void resumeAll() => _setPaused({});
/// 시작할 때 고른 쌍만 (나머지는 멈춤)
void runOnly(Iterable<LiveSyncPair> xs) {
final keep = xs.map(keyOf).toSet();
_setPaused({for (final x in c.settings.liveSyncPairs) if (!keep.contains(keyOf(x))) keyOf(x)});
_setPaused({for (final x in c.settings.copy.liveSyncPairs) if (!keep.contains(keyOf(x))) keyOf(x)});
}
void _setPaused(Set<String> v) {
@ -153,10 +153,10 @@ class LiveSync extends ChangeNotifier {
/// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로.
/// 관리자 지시 (10/19): rsync 쌍이 원격이라 앱이 맞출 때 (그리고 그 대기 목록) 도 환경 설정 rsync 옵션의 거르기를 따른다.
/// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이
RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none;
RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.copy.rsyncOptions) : RsyncFilter.none;
/// 지울 것에 영향을 주는 옵션 (rsync 쌍만 - 거르기 · --delete-excluded). 확인할 때 쌍에 기억한다 ([LiveSyncPair.confirmedOptions])
String _optionsOf(LiveSyncPair x) => x.method == 'rsync' ? c.settings.rsyncOptions : '';
String _optionsOf(LiveSyncPair x) => x.method == 'rsync' ? c.settings.copy.rsyncOptions : '';
/// 관리자 3a 판단 (10/20): 확인한 쌍이라도 옵션이 바뀌어 지울 것이 늘었으면 다시 묻는다 (로컬 · 원격 쌍 모두).
/// 늘지 않았으면 (줄거나 같음) 묻지 않고 지금 옵션을 기억한다. 예전에 확인한 쌍 (기억한 옵션 없음) 은 예전 동작
@ -419,8 +419,8 @@ class LiveSync extends ChangeNotifier {
}
/// 설정의 쌍을 바꾼다 (같은 원본 → 대상)
Future<void> _setPair(LiveSyncPair old, LiveSyncPair now) => c.updateSettings((s) => s.liveSyncPairs = [
for (final p in s.liveSyncPairs) keyOf(p) == keyOf(old) ? now : p,
Future<void> _setPair(LiveSyncPair old, LiveSyncPair now) => c.updateSettings((s) => s.copy.liveSyncPairs = [
for (final p in s.copy.liveSyncPairs) keyOf(p) == keyOf(old) ? now : p,
]);
/// 지울 목록을 보고 결정: [delete] true = 지우기 포함으로 맞추기 (확인함), false = 지우기 끄기. 그 뒤 바로 맞춘다
@ -449,7 +449,7 @@ class LiveSync extends ChangeNotifier {
void _apply() {
final s = c.settings;
final pairs = watching;
final sig = jsonEncode([for (final x in pairs) x.toJson(), s.liveSyncIntervalSec]);
final sig = jsonEncode([for (final x in pairs) x.toJson(), s.copy.liveSyncIntervalSec]);
if (sig == _sig) return;
_sig = sig;
_stopAll();
@ -475,11 +475,11 @@ class LiveSync extends ChangeNotifier {
_schedule(x);
});
} catch (_) {
_timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x));
_timers[k] = Timer.periodic(Duration(seconds: s.copy.liveSyncIntervalSec), (_) => _schedule(x));
}
} else {
// Android 등 · 원본이 WebDAV: 폴더 안쪽까지 감시가 안 되므로 정해진 간격으로 살핀다
_timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x));
_timers[k] = Timer.periodic(Duration(seconds: s.copy.liveSyncIntervalSec), (_) => _schedule(x));
}
_schedule(x); // 시작할 때 한 번
}
@ -581,7 +581,7 @@ class LiveSync extends ChangeNotifier {
// 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 맞춘다 (크기 · 시각 비교)
final method = isDav(x.source) || isDav(x.target) ? CopyMethod.builtin : CopyMethod.of(x.method);
// M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다
final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps);
final ops = FileOps(bandwidthKBps: s.copy.bandwidthKBps);
final n = switch (method) {
CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty, filter: _filterOf(x)),
CopyMethod.rsync => await _rsync(x, s),
@ -650,15 +650,15 @@ class LiveSync extends ChangeNotifier {
// 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다
// 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs)
...rsyncFileOptionArgs([
for (final o in splitOptions(s.rsyncOptions))
for (final o in splitOptions(s.copy.rsyncOptions))
if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o,
], windows: windows),
if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}',
if (s.copy.bandwidthKBps > 0) '--bwlimit=${s.copy.bandwidthKBps}',
if (x.delete) '--delete',
// 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창
// (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름)
if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded',
if (x.delete && splitOptions(s.copy.rsyncOptions).contains('--delete-excluded')) '--delete-excluded',
...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다
'${toCygwinPath(x.source, windows: windows)}/',
'${rsyncDestPath(x.target, windows: windows)}/', // 받는 쪽 UNC 는 \\서버\… (28 · 15 확인 10/20)
@ -667,7 +667,7 @@ class LiveSync extends ChangeNotifier {
/// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97
static List<String> robocopyArgsFor(LiveSyncPair x, AppSettings s) {
final opts = [
for (final o in splitOptions(s.robocopyOptions))
for (final o in splitOptions(s.copy.robocopyOptions))
if (o.toUpperCase() == '/MIR') '/E' else if (o.toUpperCase() != '/PURGE' && !isSourceRemovingOption(o)) o,
];
return [
@ -679,7 +679,7 @@ class LiveSync extends ChangeNotifier {
if (!robocopyLinkOption(opts)) '/XJ',
if (x.delete) '/PURGE',
...appTempRobocopyExcludes, // 78 보충
if (s.copyBandwidthKBps > 0) '/IPG:${robocopyIpg(s.copyBandwidthKBps)}',
if (s.copy.bandwidthKBps > 0) '/IPG:${robocopyIpg(s.copy.bandwidthKBps)}',
'/BYTES', '/NJH', '/NJS', '/NDL', '/NP',
];
}

View File

@ -16,6 +16,7 @@ import '../services/app_shell.dart' show appIconOf;
import '../core/remote_idle.dart';
import 'settings/records.dart';
import 'settings/settings_group.dart';
import 'settings/copy_settings.dart';
import 'settings/tor_settings.dart';
import 'settings/network_settings.dart';
import 'settings/explorer_settings.dart';
@ -23,6 +24,7 @@ import 'settings/explorer_settings.dart';
export 'settings/records.dart';
export 'settings/settings_group.dart';
export 'settings/settings_store.dart';
export 'settings/copy_settings.dart';
export 'settings/tor_settings.dart';
export 'settings/network_settings.dart';
export 'settings/explorer_settings.dart';
@ -226,42 +228,18 @@ class AppSettings {
// ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
/// 파일 탐색기의 복사 · 이동 방법: 'builtin' 현재 방식 (기본) · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때.
/// rsync 는 Rsync 화면에서 따로 (파일 탐색기에서는 쓰지 않음)
String copyMethodFile = 'builtin';
/// 48: 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 'ask' (확인 창에서 고름, 처음 값) · 'rename' · 'overwrite' · 'skip'
String copyConflict = 'ask';
/// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름.
/// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로)
String mkvExisting = 'ask';
/// 229: 앱이 직접 하는 복사 · 이동 (현재 방식 · WebDAV 의 안의 것 맞추기) 에서 받는 쪽이 더 새것이면 건너뛴다 (rsync -u 와 같음).
/// 처음 값 켜짐 - 예전 원본으로 새 파일을 덮어쓰고 (이동이면 원본까지 지워) 새 내용을 잃었다 (관리자 판단)
bool copySkipNewer = true;
String copyMethodFolder = 'builtin';
String rsyncOptions = '-avPog';
String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
/// 여러 개를 고르면: 'each' 항목마다 따로 실행 (기본) · 'once' 한 번에 (rsync)
String copyRunMode = 'each';
/// 속도 제한 KB/s (0 = 제한 없음). 모든 방법에 (rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게)
int copyBandwidthKBps = 0;
/// rsync 가져오기: 'download' 앱에 들어 있는 것 (기본, Windows 는 없으면 내려받기) · 'custom' 직접 지정한 실행 파일 ([rsyncPath])
String rsyncSource = 'download';
String rsyncPath = '';
/// 실시간 동기화 (lsyncd 처럼)
List<LiveSyncPair> liveSyncPairs = [];
/// 모니터링 (Rsync 화면 가운데 [모니터링]): 실행한 rsync 를 기억해 다시 실행 · 옵션 고치기 · lsync 로 옮기기
List<CopyTask> copyTasks = [];
/// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽)
List<String> rsyncPaths = [];
/// 설치한 (켠) 컴포넌트 id (app/components.dart). 처음엔 기본으로 들어 있는 것 모두
List<String> components = AppComponent.defaultInstalled;
@ -303,8 +281,6 @@ class AppSettings {
/// 99 (Windows): 시작 메뉴에 등록 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다
bool startMenuShortcut = true;
/// 92 (Windows): 실시간 동기화가 멈췄을 때 Windows 알림
bool syncStopToast = true;
// ── 102: 사용자에게 물어 정한 것 · 관리자 1차 판단 (기본값 = 그 답) ──
@ -432,15 +408,11 @@ class AppSettings {
/// 저장한 뒤 'upscaled' 올린 것으로 계속 보기 · 'original' 원본으로
String aiUpAfter = 'upscaled';
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
int liveSyncIntervalSec = 30;
/// 백그라운드로 실행 (Android): 켜면 ← 로 닫거나 최근 앱에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속한다 (알림에 표시).
/// 끄면 지금처럼 닫을 때 끝난다. Windows 는 [closeAction] 'background' 가 같은 뜻 ([backgroundRun]).
bool runInBackground = true;
/// 앱을 다시 켤 때 실시간 동기화: 'auto' 바로 시작 / 'ask' 골라서 시작 / 'off' 시작 안 함 (모니터링에서 시작)
String liveSyncOnStart = 'auto';
/// 백그라운드로 실행 (설정 화면의 체크 하나). Windows: 창 ✕ 를 누르면 트레이로 (끄면 종료), Android: [runInBackground]
bool get backgroundRun => Platform.isWindows ? closeAction == 'background' : runInBackground;
@ -499,8 +471,11 @@ class AppSettings {
/// Tor 釉뚮씪?곗? (?ъ슜???붿껌 10/10)
final tor = TorBrowserSettings();
/// 복사 · 이동 (파일 탐색기) · rsync · 실시간 동기화 (Rsync 화면)
final copy = CopySettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor];
List<SettingsGroup> get groups => [explorer, network, tor, copy];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -571,12 +546,7 @@ class AppSettings {
'rootEnabled': rootEnabled,
'vaultMasterCopy': vaultMasterCopy,
'vaultAutoLockSeconds': vaultAutoLockSeconds,
'copyMethodFile': copyMethodFile,
'copyConflict': copyConflict,
'mkvExisting': mkvExisting,
'copySkipNewer': copySkipNewer,
'copyMethodFolder': copyMethodFolder,
'rsyncPaths': rsyncPaths,
'components': components,
'aiImageV1': true,
'robocopyLinksV1': true,
@ -592,7 +562,6 @@ class AppSettings {
'readerVolumeKeys': readerVolumeKeys,
'readerSplit': readerSplit,
'startMenuShortcut': startMenuShortcut,
'syncStopToast': syncStopToast,
'swipeWrap': swipeWrap,
'browserSwipeHinted': browserSwipeHinted,
'cleanupPrechecked': cleanupPrechecked,
@ -647,18 +616,8 @@ class AppSettings {
'smbRequireSecure': smbRequireSecure,
'remoteIdleSeconds': remoteIdleSeconds,
'ftpServers': [for (final x in ftpServers) x.toJson()],
'rsyncOptions': rsyncOptions,
'robocopyOptions': robocopyOptions,
'copyRunMode': copyRunMode,
'copyBandwidthKBps': copyBandwidthKBps,
'rsyncSource': rsyncSource,
'rsyncPath': rsyncPath,
'liveSyncPairs': [for (final x in liveSyncPairs) x.toJson()],
'liveSyncIntervalSec': liveSyncIntervalSec,
'runInBackground': runInBackground,
'backgroundV2': true,
'liveSyncOnStart': liveSyncOnStart,
'copyTasks': [for (final x in copyTasks) x.toJson()],
'autoCheckUpdates': autoCheckUpdates,
'lastUpdateCheck': lastUpdateCheck,
'lastUpdateCheckVersion': lastUpdateCheckVersion,
@ -689,8 +648,6 @@ class AppSettings {
return j;
}
/// 파일 탐색기의 복사 방법 (rsync 는 Rsync 화면으로 옮겨 예전 값 rsync 도 현재 방식으로)
static String _method(Object? v) => v == 'robocopy' ? 'robocopy' : 'builtin';
/// 색 보정 값 (-100 ~ 100)
static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100);
@ -762,12 +719,7 @@ class AppSettings {
..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30
..wifiShareTailscale = j['wifiShareTailscale'] is bool ? j['wifiShareTailscale'] as bool : true
..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : ''
..copyMethodFile = _method(j['copyMethodFile'])
..copyConflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask'
..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask'
..copySkipNewer = j['copySkipNewer'] != false
..copyMethodFolder = _method(j['copyMethodFolder'])
..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x']
..components = j['components'] is List
? [
// 225: 이 판이 모르는 구성 요소 (새 판의 Tor 등) 도 지우지 않고 그대로 둔다 - 예전 판을 거쳐 다시 올려도 켠 것이 남게 (40 과 같은 원칙).
@ -794,7 +746,6 @@ class AppSettings {
..readerVolumeKeys = j['readerVolumeKeys'] as bool? ?? false
..readerSplit = const ['auto', 'on', 'off'].contains(j['readerSplit']) ? j['readerSplit'] as String : 'auto'
..startMenuShortcut = j['startMenuShortcut'] != false
..syncStopToast = j['syncStopToast'] != false
..swipeWrap = j['swipeWrap'] != false
..browserSwipeHinted = j['browserSwipeHinted'] == true
..cleanupPrechecked = j['cleanupPrechecked'] is List
@ -863,24 +814,8 @@ class AppSettings {
for (final x in (j['ftpServers'] as List?) ?? const [])
if (x is Map) FtpServer.fromJson(x),
]
..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog'
..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2'
..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each'
..copyBandwidthKBps = ((j['copyBandwidthKBps'] as num?)?.toInt() ?? 0).clamp(0, 10000000)
..rsyncSource = j['rsyncSource'] == 'custom' ? 'custom' : 'download'
..rsyncPath = j['rsyncPath'] as String? ?? ''
..liveSyncPairs = [
for (final x in (j['liveSyncPairs'] as List?) ?? const [])
if (x is Map && x['source'] is String && x['target'] is String) LiveSyncPair.fromJson(x),
]
..liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600)
// 사용자 결정 (10/8): 기본 켜짐. 예전 기본 (꺼짐) 은 한 번 켜짐으로
..runInBackground = j['backgroundV2'] == true ? j['runInBackground'] != false : true
..liveSyncOnStart = const ['auto', 'ask', 'off'].contains(j['liveSyncOnStart']) ? j['liveSyncOnStart'] as String : 'auto'
..copyTasks = [
for (final x in (j['copyTasks'] as List?) ?? const [])
if (x is Map && x['dest'] is String) CopyTask.fromJson(x),
]
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? ''
..lastUpdateCheckVersion = j['lastUpdateCheckVersion'] as String? ?? ''

View File

@ -0,0 +1,102 @@
import 'records.dart';
import 'settings_group.dart';
/// 약속 (설정 A): 환경 설정 > 복사 · 이동 (파일 탐색기) · rsync · 실시간 동기화 (Rsync 화면) - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다.
/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키)
class CopySettings implements SettingsGroup {
/// 파일 탐색기의 복사 · 이동 방법: 'builtin' 현재 방식 (기본) · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때.
/// rsync 는 Rsync 화면에서 따로 (파일 탐색기에서는 쓰지 않음)
String methodFile = 'builtin';
/// 48: 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 'ask' (확인 창에서 고름, 처음 값) · 'rename' · 'overwrite' · 'skip'
String conflict = 'ask';
/// 229: 앱이 직접 하는 복사 · 이동 (현재 방식 · WebDAV 의 안의 것 맞추기) 에서 받는 쪽이 더 새것이면 건너뛴다 (rsync -u 와 같음).
/// 처음 값 켜짐 - 예전 원본으로 새 파일을 덮어쓰고 (이동이면 원본까지 지워) 새 내용을 잃었다 (관리자 판단)
bool skipNewer = true;
String methodFolder = 'builtin';
String rsyncOptions = '-avPog';
String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
/// 여러 개를 고르면: 'each' 항목마다 따로 실행 (기본) · 'once' 한 번에 (rsync)
String runMode = 'each';
/// 속도 제한 KB/s (0 = 제한 없음). 모든 방법에 (rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게)
int bandwidthKBps = 0;
/// rsync 가져오기: 'download' 앱에 들어 있는 것 (기본, Windows 는 없으면 내려받기) · 'custom' 직접 지정한 실행 파일 ([rsyncPath])
String rsyncSource = 'download';
String rsyncPath = '';
/// 실시간 동기화 (lsyncd 처럼)
List<LiveSyncPair> liveSyncPairs = [];
/// 모니터링 (Rsync 화면 가운데 [모니터링]): 실행한 rsync 를 기억해 다시 실행 · 옵션 고치기 · lsync 로 옮기기
List<CopyTask> tasks = [];
/// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽)
List<String> rsyncPaths = [];
/// 92 (Windows): 실시간 동기화가 멈췄을 때 Windows 알림
bool syncStopToast = true;
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
int liveSyncIntervalSec = 30;
/// 앱을 다시 켤 때 실시간 동기화: 'auto' 바로 시작 / 'ask' 골라서 시작 / 'off' 시작 안 함 (모니터링에서 시작)
String liveSyncOnStart = 'auto';
/// 파일 탐색기의 복사 방법 (rsync 는 Rsync 화면으로 옮겨 예전 값 rsync 도 현재 방식으로)
static String _method(Object? v) => v == 'robocopy' ? 'robocopy' : 'builtin';
@override
void write(Map<String, Object?> j, {required bool plainSecrets}) {
j['copyMethodFile'] = methodFile;
j['copyConflict'] = conflict;
j['copySkipNewer'] = skipNewer;
j['copyMethodFolder'] = methodFolder;
j['rsyncOptions'] = rsyncOptions;
j['robocopyOptions'] = robocopyOptions;
j['copyRunMode'] = runMode;
j['copyBandwidthKBps'] = bandwidthKBps;
j['rsyncSource'] = rsyncSource;
j['rsyncPath'] = rsyncPath;
j['liveSyncPairs'] = [for (final x in liveSyncPairs) x.toJson()];
j['liveSyncIntervalSec'] = liveSyncIntervalSec;
j['liveSyncOnStart'] = liveSyncOnStart;
j['copyTasks'] = [for (final x in tasks) x.toJson()];
j['rsyncPaths'] = rsyncPaths;
j['syncStopToast'] = syncStopToast;
}
@override
void read(Map<String, dynamic> j) {
methodFile = _method(j['copyMethodFile']);
conflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask';
skipNewer = j['copySkipNewer'] != false;
methodFolder = _method(j['copyMethodFolder']);
rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x'];
syncStopToast = j['syncStopToast'] != false;
rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog';
robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
runMode = j['copyRunMode'] == 'once' ? 'once' : 'each';
bandwidthKBps = ((j['copyBandwidthKBps'] as num?)?.toInt() ?? 0).clamp(0, 10000000);
rsyncSource = j['rsyncSource'] == 'custom' ? 'custom' : 'download';
rsyncPath = j['rsyncPath'] as String? ?? '';
liveSyncPairs = [
for (final x in (j['liveSyncPairs'] as List?) ?? const [])
if (x is Map && x['source'] is String && x['target'] is String) LiveSyncPair.fromJson(x),
];
liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600);
liveSyncOnStart = const ['auto', 'ask', 'off'].contains(j['liveSyncOnStart']) ? j['liveSyncOnStart'] as String : 'auto';
tasks = [
for (final x in (j['copyTasks'] as List?) ?? const [])
if (x is Map && x['dest'] is String) CopyTask.fromJson(x),
];
}
}

View File

@ -148,7 +148,7 @@ Future<void> main(List<String> args) async {
await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저)
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
await findBundledRsync(); // 앱에 들어 있는 rsync
final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto');
final live = LiveSync(controller)..start(hold: controller.settings.copy.liveSyncOnStart != 'auto');
// 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false);
@ -286,7 +286,7 @@ Future<void> main(List<String> args) async {
?.showSnackBar(SnackBar(content: Text(trf('다운로드 추가: {0}', [t.source])))));
// 92: 트레이에 둔 채 실시간 동기화가 멈추면 Windows 알림 · 트레이 툴팁, 누르면 모니터링 > lsync 카드로
final syncAlert = SyncAlert(live, services.shell,
toastEnabled: () => controller.settings.syncStopToast,
toastEnabled: () => controller.settings.copy.syncStopToast,
openLsync: () {
final nav = navigatorKey.currentState;
if (nav != null) unawaited(nav.push(MaterialPageRoute<void>(builder: (_) => MonitorPage(c: controller, initialTab: 1))));
@ -399,7 +399,7 @@ Future<void> main(List<String> args) async {
if (home != 'mkv' && nav0 != null) {
unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks));
}
if (controller.settings.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live);
if (controller.settings.copy.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live);
// 설정 파일을 읽지 못했으면 (백업으로 되살렸거나 처음 설정으로 켰으면) 알린다
if (ctx.mounted) await showSettingsProblem(ctx, controller);
watchSecretIssue(messengerKey, controller);
@ -595,7 +595,7 @@ Future<void> runAndroid(String dataDir) async {
await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저)
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
await findBundledRsync(); // 앱에 들어 있는 rsync
final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto');
final live = LiveSync(controller)..start(hold: controller.settings.copy.liveSyncOnStart != 'auto');
// 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false);
@ -703,7 +703,7 @@ Future<void> runAndroid(String dataDir) async {
}
// 다시 켤 때 실시간 동기화를 골라서 시작
final ctx0 = navigatorKey.currentContext;
if (controller.settings.liveSyncOnStart == 'ask' && ctx0 != null && ctx0.mounted) await askLiveSyncStart(ctx0, live);
if (controller.settings.copy.liveSyncOnStart == 'ask' && ctx0 != null && ctx0.mounted) await askLiveSyncStart(ctx0, live);
// 다른 버전을 쓰다가 돌아왔거나 앱을 다시 설치했으면 보관해 둔 설정을 되살릴지 (되살리면 앱을 끝내고 다시 켜 달라고)
final current = await _currentVersion(services);
final ctxV = navigatorKey.currentContext;

View File

@ -157,7 +157,7 @@ Future<String?> extractArchiveFlow(BuildContext context, AppController c, String
return null;
}
var dest = choices.first.$1;
var conflict = switch (c.settings.copyConflict) { 'overwrite' => NameConflict.overwrite, 'skip' => NameConflict.skip, _ => NameConflict.rename };
var conflict = switch (c.settings.copy.conflict) { 'overwrite' => NameConflict.overwrite, 'skip' => NameConflict.skip, _ => NameConflict.rename };
Future<int?> free(String d) async {
var x = d;
while (!Directory(x).existsSync() && p.dirname(x) != x) {
@ -220,7 +220,7 @@ Future<String?> extractArchiveFlow(BuildContext context, AppController c, String
// 관리자 판단: 덮어쓴 파일은 휴지통에 남지 않는다 - 되돌릴 수 없다고 알린다 (받는 쪽이 더 새것이면 설정대로 그대로 둠)
if (conflict == NameConflict.overwrite)
Text(
c.settings.copySkipNewer
c.settings.copy.skipNewer
? tr('덮어쓴 파일은 되돌릴 수 없습니다 (받는 쪽이 더 새 파일은 그대로 둡니다).')
: tr('덮어쓴 파일은 되돌릴 수 없습니다.'),
style: TextStyle(color: JjColors.warning, fontSize: 12)),
@ -253,13 +253,13 @@ Future<String?> extractArchiveFlow(BuildContext context, AppController c, String
if (kind == ArchiveKind.zip) {
return extractZipPlan(local, dest, plan,
conflict: conflict,
skipNewer: c.settings.copySkipNewer,
skipNewer: c.settings.copy.skipNewer,
password: pw,
cancelled: cancelled,
onProgress: (d, t) => set(t == 0 ? null : d / t));
}
return extract7zPlan(exe!, local, dest, plan,
conflict: conflict, skipNewer: c.settings.copySkipNewer, password: pw, cancelled: cancelled, onProgress: set);
conflict: conflict, skipNewer: c.settings.copy.skipNewer, password: pw, cancelled: cancelled, onProgress: set);
});
say([
trf('{0}개를 풀었습니다: {1}', [r.done, vDisplay(dest)]),

View File

@ -125,36 +125,36 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
title: Text(tr('파일 복사 · 이동 방법')),
subtitle: Text(tr('파일만 골랐을 때')),
trailing: DropdownButton<String>(
value: _valid(s.copyMethodFile),
value: _valid(s.copy.methodFile),
items: _methods(),
onChanged: (v) => c.updateSettings((x) => x.copyMethodFile = v!),
onChanged: (v) => c.updateSettings((x) => x.copy.methodFile = v!),
),
),
SettingTile(
title: Text(tr('폴더 복사 · 이동 방법')),
subtitle: Text(tr('폴더가 들어 있을 때')),
trailing: DropdownButton<String>(
value: _valid(s.copyMethodFolder),
value: _valid(s.copy.methodFolder),
items: _methods(),
onChanged: (v) => c.updateSettings((x) => x.copyMethodFolder = v!),
onChanged: (v) => c.updateSettings((x) => x.copy.methodFolder = v!),
),
),
_options(tr('robocopy 옵션'), tr('예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임'), s.robocopyOptions,
defaultRobocopyOptions, (x, v) => x.robocopyOptions = v),
_options(tr('robocopy 옵션'), tr('예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임'), s.copy.robocopyOptions,
defaultRobocopyOptions, (x, v) => x.copy.robocopyOptions = v),
],
// 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때, robocopy · rsync 는 그 프로그램의 규칙)
SettingTile(
title: Text(tr('같은 이름이 있을 때')),
subtitle: Text(tr('복사 · 이동할 폴더에 같은 이름이 있으면 (현재 방식 · WebDAV)')),
trailing: DropdownButton<String>(
value: const ['ask', 'rename', 'overwrite', 'skip'].contains(s.copyConflict) ? s.copyConflict : 'ask',
value: const ['ask', 'rename', 'overwrite', 'skip'].contains(s.copy.conflict) ? s.copy.conflict : 'ask',
items: [
DropdownMenuItem(value: 'ask', child: Text(tr('묻기'))),
DropdownMenuItem(value: 'rename', child: Text(tr('늘 이름 바꾸기'))),
DropdownMenuItem(value: 'overwrite', child: Text(tr('늘 덮어쓰기'))),
DropdownMenuItem(value: 'skip', child: Text(tr('늘 건너뛰기'))),
],
onChanged: (v) => c.updateSettings((x) => x.copyConflict = v ?? 'ask'),
onChanged: (v) => c.updateSettings((x) => x.copy.conflict = v ?? 'ask'),
),
),
_skipNewerTile(),
@ -165,12 +165,12 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
trailing: SizedBox(
width: 140,
child: TextFormField(
initialValue: '${s.copyBandwidthKBps}',
initialValue: '${s.copy.bandwidthKBps}',
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder(), suffixText: 'KB/s'),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (v) => c.updateSettings((x) => x.copyBandwidthKBps = int.tryParse(v) ?? 0),
onChanged: (v) => c.updateSettings((x) => x.copy.bandwidthKBps = int.tryParse(v) ?? 0),
),
),
),
@ -180,8 +180,8 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
/// Rsync 화면: rsync 옵션 · rsync 가져오기 · 실시간 동기화 (lsync)
/// 229: 받는 쪽이 더 새것이면 건너뛰기 - 파일 탐색기 · Rsync 탭 두 곳에 같은 설정 (Rsync 화면 · 모니터링에서도 쓰여 찾기 쉽게)
Widget _skipNewerTile() => SwitchListTile(
value: c.settings.copySkipNewer,
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v),
value: c.settings.copy.skipNewer,
onChanged: (v) => c.updateSettings((x) => x.copy.skipNewer = v),
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
subtitle: Text(keepOptionsTogether(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다'))),
);
@ -193,7 +193,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
// 111: 이미 실행한 쌍은 기억한 옵션을 쓴다는 것을 알린다
_options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. '
'새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'),
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v, rsync: true),
s.copy.rsyncOptions, defaultRsyncOptions, (x, v) => x.copy.rsyncOptions = v, rsync: true),
_skipNewerTile(),
SettingTile(
title: Text(tr('rsync 가져오기')),
@ -201,14 +201,14 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
? tr('확인 중…')
: _rsync != null
? trf('사용: {0}', [_rsync!])
: s.rsyncSource == 'download'
: s.copy.rsyncSource == 'download'
? (desk
? tr('이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)')
: tr('이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.'))
: tr('지정한 파일이 없습니다')),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
// 내려받기 · 내려받은 것 지우기 (앱에 들어 있는 것을 쓰면 보이지 않음)
if (desk && s.rsyncSource == 'download' && !_checking && (_rsync == null || _rsync != bundledRsync))
if (desk && s.copy.rsyncSource == 'download' && !_checking && (_rsync == null || _rsync != bundledRsync))
_rsync == null
? TextButton(
onPressed: () async {
@ -227,28 +227,28 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
),
const SizedBox(width: 8),
DropdownButton<String>(
value: s.rsyncSource,
value: s.copy.rsyncSource,
items: [
DropdownMenuItem(value: 'download', child: Text(tr('앱에 들어 있는 것 (기본)'))),
DropdownMenuItem(value: 'custom', child: Text(tr('직접 지정'))),
],
onChanged: (v) async {
await c.updateSettings((x) => x.rsyncSource = v!);
await c.updateSettings((x) => x.copy.rsyncSource = v!);
await _check();
},
),
]),
),
if (s.rsyncSource == 'custom')
if (s.copy.rsyncSource == 'custom')
SettingTile(
title: Text(tr('rsync 실행 파일')),
subtitle: Text(s.rsyncPath.isEmpty ? tr('고르지 않음') : s.rsyncPath),
subtitle: Text(s.copy.rsyncPath.isEmpty ? tr('고르지 않음') : s.copy.rsyncPath),
trailing: TextButton(
onPressed: () async {
final r = await FilePicker.pickFiles(dialogTitle: tr('rsync 실행 파일'));
final path = r.isEmpty ? null : r.single.path;
if (path == null) return;
await c.updateSettings((x) => x.rsyncPath = path);
await c.updateSettings((x) => x.copy.rsyncPath = path);
await _check();
},
child: Text(tr('고르기')),
@ -275,8 +275,8 @@ Future<void> removeLiveSyncPair(BuildContext context, AppController c, LiveSyncP
ok: tr('지우기'),
);
if (!ok) return;
await c.updateSettings((y) => y.liveSyncPairs = [
for (final o in y.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o,
await c.updateSettings((y) => y.copy.liveSyncPairs = [
for (final o in y.copy.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o,
]);
}
@ -374,7 +374,7 @@ class _LiveSyncTile extends StatelessWidget {
if (ok != true) return;
if (old == null) {
await c.updateSettings((x) =>
x.liveSyncPairs = [...x.liveSyncPairs, LiveSyncPair(src!, dst!, method: method, delete: delete, schedule: schedule)]);
x.copy.liveSyncPairs = [...x.copy.liveSyncPairs, LiveSyncPair(src!, dst!, method: method, delete: delete, schedule: schedule)]);
return;
}
// 원본 · 대상이 바뀌면 새 쌍 (지우기 확인도 새로 - 42), 아니면 방법 · 지우기 · 일정만
@ -382,21 +382,21 @@ class _LiveSyncTile extends StatelessWidget {
final now = same
? old.copyWith(method: method, delete: delete, schedule: schedule)
: LiveSyncPair(src!, dst!, method: method, delete: delete, schedule: schedule, enabled: old.enabled);
await c.updateSettings((x) => x.liveSyncPairs = [
for (final o in x.liveSyncPairs) o.source == old.source && o.target == old.target ? now : o,
await c.updateSettings((x) => x.copy.liveSyncPairs = [
for (final o in x.copy.liveSyncPairs) o.source == old.source && o.target == old.target ? now : o,
]);
}
@override
Widget build(BuildContext context) {
final live = LiveSync.instance;
final pairs = c.settings.liveSyncPairs;
final pairs = c.settings.copy.liveSyncPairs;
Widget body() => Column(children: [
SettingTile(
title: Text(tr('실시간 동기화 (lsyncd 처럼)')),
subtitle: Text(Platform.isWindows
? tr('원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.')
: trf('{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).', [c.settings.liveSyncIntervalSec])),
: trf('{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).', [c.settings.copy.liveSyncIntervalSec])),
trailing: TextButton.icon(
onPressed: () => _add(context, c),
icon: const Icon(Icons.add, size: 18),
@ -414,13 +414,13 @@ class _LiveSyncTile extends StatelessWidget {
? tr('WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.')
: tr('짧을수록 빨리 맞추지만 배터리를 더 씁니다.')),
trailing: DropdownButton<int>(
value: c.settings.liveSyncIntervalSec,
value: c.settings.copy.liveSyncIntervalSec,
items: [
for (final v in {5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600, c.settings.liveSyncIntervalSec}.toList()..sort())
for (final v in {5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600, c.settings.copy.liveSyncIntervalSec}.toList()..sort())
DropdownMenuItem(value: v, child: Text(v < 60 ? trf('{0}초', [v]) : trf('{0}분', [v ~/ 60]))),
],
onChanged: (v) {
if (v != null) c.updateSettings((x) => x.liveSyncIntervalSec = v);
if (v != null) c.updateSettings((x) => x.copy.liveSyncIntervalSec = v);
},
),
),
@ -452,7 +452,7 @@ class _LiveSyncTile extends StatelessWidget {
onPressed: () async {
final r = await editSchedule(context, pairs[i].schedule);
if (r == null) return;
await c.updateSettings((x) => x.liveSyncPairs = [
await c.updateSettings((x) => x.copy.liveSyncPairs = [
for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(schedule: r) : pairs[k],
]);
},
@ -465,7 +465,7 @@ class _LiveSyncTile extends StatelessWidget {
Switch(
value: pairs[i].enabled,
onChanged: (v) => c.updateSettings(
(x) => x.liveSyncPairs = [for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(enabled: v) : pairs[k]]),
(x) => x.copy.liveSyncPairs = [for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(enabled: v) : pairs[k]]),
),
IconButton(
tooltip: tr('고치기'),
@ -501,14 +501,14 @@ class LiveSyncRunOptions extends StatelessWidget {
Widget build(BuildContext context) {
final s = c.settings;
final onStart = DropdownButton<String>(
value: s.liveSyncOnStart,
value: s.copy.liveSyncOnStart,
isDense: dense,
items: [
DropdownMenuItem(value: 'auto', child: Text(tr('바로 시작 (기본)'))),
DropdownMenuItem(value: 'ask', child: Text(tr('골라서 시작'))),
DropdownMenuItem(value: 'off', child: Text(tr('시작 안 함 (모니터링에서 시작)'))),
],
onChanged: (v) => c.updateSettings((x) => x.liveSyncOnStart = v!),
onChanged: (v) => c.updateSettings((x) => x.copy.liveSyncOnStart = v!),
);
void setBackground(bool? v) => c.updateSettings((x) => x.backgroundRun = v ?? false);
if (dense) {

View File

@ -407,7 +407,7 @@ class ExplorerController extends ChangeNotifier {
final i = panes.indexOf(q);
final paths = [...panes.map((x) => x.current)];
if (rsync) {
unawaited(c.updateSettings((s) => s.rsyncPaths = paths));
unawaited(c.updateSettings((s) => s.copy.rsyncPaths = paths));
} else {
final hist = [dir, ...c.settings.explorer.history.where((h) => !samePath(h, dir))].take(30).toList();
unawaited(c.updateSettings((s) => s

View File

@ -47,7 +47,7 @@ class ExplorerRsyncFlow {
allowInnerToOuter: ctl.c.settings.allowInnerToOuter,
jobs: this.jobs.list,
viaDav: l != null && r != null && ExplorerTransfers.viaDav([l, r]),
copySkipNewer: ctl.c.settings.copySkipNewer);
copySkipNewer: ctl.c.settings.copy.skipNewer);
if (run == null) {
ctl.host.snack(problem!);
if (missing) {

View File

@ -99,7 +99,7 @@ class ExplorerTransfers extends ChangeNotifier {
final have = await rsyncExecutable(s);
if (have != null) return have;
if (!context.mounted) return null;
if (s.rsyncSource == 'custom' || !Platform.isWindows) {
if (s.copy.rsyncSource == 'custom' || !Platform.isWindows) {
ctl.host.snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.'));
return null;
}
@ -174,7 +174,7 @@ class ExplorerTransfers extends ChangeNotifier {
// 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽)
final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const <NameClash>[]);
// 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만)
final always = switch (ctl.c.settings.copyConflict) {
final always = switch (ctl.c.settings.copy.conflict) {
'rename' => NameConflict.rename,
'overwrite' => NameConflict.overwrite,
'skip' => NameConflict.skip,
@ -240,7 +240,7 @@ class ExplorerTransfers extends ChangeNotifier {
? tr('안쪽 파일을 확인하는 중 …')
: (n.data ?? 0) == 0
? ''
: ctl.c.settings.copySkipNewer
: ctl.c.settings.copy.skipNewer
? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data])
: trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]),
style: TextStyle(fontSize: 12, color: JjColors.warning),

View File

@ -184,7 +184,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.explorer.androidTrashDays).catchError((_) => 0));
if (Platform.isAndroid) unawaited(_access.checkAccess());
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorer.paths;
final saved = widget.rsync ? c.settings.copy.rsyncPaths : c.settings.explorer.paths;
// 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게
// 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유)
final reach = await Future.wait(

View File

@ -120,12 +120,12 @@ class _CopyTaskCardState extends State<CopyTaskCard> {
FocusManager.instance.primaryFocus?.unfocus();
// 104: 지우기가 들어 있으면 Rsync 화면과 같은 미리 보기를 거친다 (그사이 대상에 생긴 파일을 묻지 않고 지우지 않게).
// 210: 이동 (원본 지우기) 도 - 카드의 [실행] 은 따로 묻는 창이 없어 곧바로 원본을 지웠다. 복사만은 지금처럼 바로
if (!await confirmDeletingRun(context, t, includeMove: true, skipNewer: widget.c.settings.copySkipNewer) || !mounted) return;
if (!await confirmDeletingRun(context, t, includeMove: true, skipNewer: widget.c.settings.copy.skipNewer) || !mounted) return;
widget.c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('모니터링의 [실행]'), t.sources.join(', '), t.dest]));
String? exe;
if (t.method == 'rsync') {
exe = await rsyncExecutable(widget.c.settings);
if (exe == null && Platform.isWindows && widget.c.settings.rsyncSource == 'download' && mounted) {
if (exe == null && Platform.isWindows && widget.c.settings.copy.rsyncSource == 'download' && mounted) {
exe = await installRsyncWithDialog(context);
}
if (exe == null) {
@ -162,7 +162,7 @@ class _CopyTaskCardState extends State<CopyTaskCard> {
content: Text(trf('칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?', [
'${_method == 'builtin' ? tr('현재 방식') : _method} ${_options.text.trim()}',
// 178: 실제로 쓰는 옵션 (원본 지우기는 이동일 때만)
effectiveRunText(t.method, t.options, move: t.move, skipNewer: widget.c.settings.copySkipNewer),
effectiveRunText(t.method, t.options, move: t.move, skipNewer: widget.c.settings.copy.skipNewer),
])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
@ -342,7 +342,7 @@ class _CopyTaskCardState extends State<CopyTaskCard> {
],
onChanged: (v) => setState(() {
if (v != _method && _options.text.trim().isEmpty || _options.text.trim() == defaultRsyncOptions || _options.text.trim() == defaultRobocopyOptions) {
_options.text = v == 'robocopy' ? s.robocopyOptions : v == 'rsync' ? s.rsyncOptions : '';
_options.text = v == 'robocopy' ? s.copy.robocopyOptions : v == 'rsync' ? s.copy.rsyncOptions : '';
}
_method = v!;
}),
@ -363,7 +363,7 @@ class _CopyTaskCardState extends State<CopyTaskCard> {
width: 460,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(
'${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}',
'${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copy.skipNewer)])}',
style: TextStyle(fontSize: 12, color: t.move ? JjColors.warning : JjColors.textDim),
),
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다)

View File

@ -147,10 +147,10 @@ Future<void> confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyn
if (ok == true) await live.syncEmptyAnyway(x);
}
/// 앱을 다시 켤 때 ([AppSettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기.
/// 앱을 다시 켤 때 ([CopySettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기.
/// 고르지 않은 것은 이번 실행 동안 멈춤 (모니터링 > lsync 에서 ▶ 로 시작).
Future<void> askLiveSyncStart(BuildContext context, LiveSync live) async {
final pairs = live.c.settings.liveSyncPairs.where((x) => x.enabled).toList();
final pairs = live.c.settings.copy.liveSyncPairs.where((x) => x.enabled).toList();
if (pairs.isEmpty) return;
final pick = {for (final x in pairs) LiveSync.keyOf(x)};
final ok = await showDialog<bool>(
@ -190,8 +190,8 @@ Future<void> askLiveSyncStart(BuildContext context, LiveSync live) async {
}
/// 같은 원본 · 대상 쌍만 [now] 로 바꿔 저장 (나머지 순서 그대로)
Future<void> replaceLiveSyncPair(AppController c, LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.liveSyncPairs = [
for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y,
Future<void> replaceLiveSyncPair(AppController c, LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.copy.liveSyncPairs = [
for (final y in x.copy.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y,
]);
/// 69: 카드의 길게 누르기 · 오른쪽 클릭 메뉴
@ -257,7 +257,7 @@ Future<bool> finalizeLiveSync(BuildContext context, AppController c, LiveSyncPai
);
if (go != true || !context.mounted) return false;
var exe = await rsyncExecutable(c.settings);
if (exe == null && Platform.isWindows && c.settings.rsyncSource == 'download' && context.mounted) {
if (exe == null && Platform.isWindows && c.settings.copy.rsyncSource == 'download' && context.mounted) {
exe = await installRsyncWithDialog(context);
}
if (exe == null) {

View File

@ -86,7 +86,7 @@ class _LiveTabState extends State<_LiveTab> {
// 열 때 맞출 것을 새로 센다
final live = LiveSync.instance;
if (live != null) {
for (final x in c.settings.liveSyncPairs) {
for (final x in c.settings.copy.liveSyncPairs) {
live.refreshPending(x);
}
}
@ -103,7 +103,7 @@ class _LiveTabState extends State<_LiveTab> {
return ListenableBuilder(
listenable: Listenable.merge([c, ?live]),
builder: (context, _) {
final pairs = c.settings.liveSyncPairs;
final pairs = c.settings.copy.liveSyncPairs;
return ListView(padding: const EdgeInsets.all(12), children: [
Row(children: [
Expanded(

View File

@ -42,13 +42,13 @@ class _RsyncSettingsSectionState extends State<RsyncSettingsSection> {
),
if (desk)
SwitchListTile(
value: s.syncStopToast,
onChanged: (v) => c.updateSettings((x) => x.syncStopToast = v),
value: s.copy.syncStopToast,
onChanged: (v) => c.updateSettings((x) => x.copy.syncStopToast = v),
title: Text(tr('동기화가 멈추면 Windows 알림')),
subtitle: Text(tr('창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)')),
),
// Windows 에서 알림이 꺼져 있으면 알리고 설정을 열어 준다 (바꾸지는 않음)
if (desk && s.syncStopToast) const ToastStatusHint(),
if (desk && s.copy.syncStopToast) const ToastStatusHint(),
]);
}
}

View File

@ -89,9 +89,9 @@ bool isPlainHttp(String url) => url.trim().toLowerCase().startsWith('http://');
List<String> davServerUsers(AppSettings s, String id) {
bool on(String path) => isDav(path) && DavPath.parse(path).server == id;
return [
for (final x in s.liveSyncPairs)
for (final x in s.copy.liveSyncPairs)
if (on(x.source) || on(x.target)) trf('실시간 동기화: {0} → {1}', [vDisplay(x.source), vDisplay(x.target)]),
for (final t in s.copyTasks)
for (final t in s.copy.tasks)
if (on(t.dest) || t.sources.any(on)) trf('기억된 작업: {0} → {1}', [t.sources.map(vDisplay).join(', '), vDisplay(t.dest)]),
];
}

View File

@ -36,9 +36,9 @@ void main() {
Directory(p.join(tmp.path, 'sub')).createSync();
c.settings
..explorer.paths = [tmp.path, tmp.path]
..rsyncPaths = [tmp.path, tmp.path]
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];
..copy.rsyncPaths = [tmp.path, tmp.path]
..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: page(c))));
for (var i = 0; i < 20; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));

View File

@ -36,7 +36,7 @@ void main() {
final tmp = Directory.systemTemp.createTempSync('jj_card_');
addTearDown(() => tmp.deleteSync(recursive: true));
final src = p.join(tmp.path, 'movies', 'src'), dst = p.join(tmp.path, 'backup', 'dst');
c.settings.liveSyncPairs = [LiveSyncPair(src, dst)];
c.settings.copy.liveSyncPairs = [LiveSyncPair(src, dst)];
final live = LiveSync(c);
LiveSync.instance = live;
addTearDown(() {
@ -61,13 +61,13 @@ void main() {
expect(find.text('실시간 동기화를 지울까요?'), findsOneWidget);
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
expect(c.settings.liveSyncPairs, hasLength(1));
expect(c.settings.copy.liveSyncPairs, hasLength(1));
// 카드의 지우기 버튼 → 확인 → 지움
await tester.tap(find.byTooltip('지우기'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '지우기'));
await tester.pumpAndSettle();
expect(c.settings.liveSyncPairs, isEmpty);
expect(c.settings.copy.liveSyncPairs, isEmpty);
});
testWidgets('46: MKV 목록 "모두 지우기" 는 확인 뒤 · 45: 대기 작업이 있으면 지금 작업만 / 모두 고르기', (tester) async {

View File

@ -27,7 +27,7 @@ void main() {
test('기본값 = 정한 답 · 저장 · 읽기', () {
final s = AppSettings();
expect([
s.startMenuShortcut, s.syncStopToast, s.swipeWrap, s.migrationNotice, s.allowInnerToOuter,
s.startMenuShortcut, s.copy.syncStopToast, s.swipeWrap, s.migrationNotice, s.allowInnerToOuter,
s.rememberPasswords, s.recycleOnDelete, s.readerSystemBars,
], everyElement(isTrue));
expect(s.cleanupPrechecked, ['work', 'download']);
@ -37,12 +37,12 @@ void main() {
expect([s.webPauseOnLeave, s.autoCheckUpdates, s.youtubeAdSkip, s.youtubeAdHide, s.runInBackground, s.swipeNav],
everyElement(isTrue));
// 탐색기 처음 배치 = 두 창 (규칙 7 · 관리자 결정 3, 10/22)
expect([s.liveSyncOnStart, s.liveSyncIntervalSec, s.explorer.layout, s.zipComic], ['auto', 30, 'dual', false]);
expect([s.copy.liveSyncOnStart, s.copy.liveSyncIntervalSec, s.explorer.layout, s.zipComic], ['auto', 30, 'dual', false]);
expect([AppSettings.uiScaleMin, AppSettings.uiScaleMax], [0.5, 2.5]);
// 바꾼 값은 남는다
final b = AppSettings.fromJson((AppSettings()
..startMenuShortcut = false
..syncStopToast = false
..copy.syncStopToast = false
..swipeWrap = false
..migrationNotice = false
..allowInnerToOuter = false
@ -52,7 +52,7 @@ void main() {
..tripleTapAction = 'none'
..davExternalOpen = 'url')
.toJson());
expect([b.startMenuShortcut, b.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords,
expect([b.startMenuShortcut, b.copy.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords,
b.recycleOnDelete], everyElement(isFalse));
expect([b.cleanupPrechecked, b.tripleTapAction, b.davExternalOpen], [['logs'], 'none', 'url']);
});

View File

@ -90,17 +90,17 @@ void main() {
..createSync(recursive: true)
..writeAsStringSync('o');
final pair = LiveSyncPair(src, dst, delete: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
await tester.runAsync(() => live.syncNow(pair));
expect(live.toDelete[LiveSync.keyOf(pair)], ['only.txt']);
await host(tester, (ctx) => confirmLiveDeletes(ctx, live, c.settings.liveSyncPairs.single));
await host(tester, (ctx) => confirmLiveDeletes(ctx, live, c.settings.copy.liveSyncPairs.single));
await dismiss(tester, how == 'cancel' ? 'back' : how); // 이 창의 "닫기" 는 [나중에]
await settle(tester, () => false, rounds: 10);
expect(File(p.join(dst, 'only.txt')).existsSync(), isTrue);
expect(c.settings.liveSyncPairs.single.delete, isTrue);
expect(c.settings.liveSyncPairs.single.deleteConfirmed, isFalse);
expect(c.settings.copy.liveSyncPairs.single.delete, isTrue);
expect(c.settings.copy.liveSyncPairs.single.deleteConfirmed, isFalse);
});
testWidgets('70 원본이 빈 쌍의 [그래도 맞추기] 창 ($how) → 지우지 않음', (tester) async {
@ -110,7 +110,7 @@ void main() {
..createSync(recursive: true)
..writeAsStringSync('k');
final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
await tester.runAsync(() => live.syncNow(pair));
@ -151,7 +151,7 @@ void main() {
final keep = File(p.join(dst, 'keep.txt'))
..createSync(recursive: true)
..writeAsStringSync('k');
c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete')];
c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete')];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -175,7 +175,7 @@ void main() {
..createSync(recursive: true)
..writeAsStringSync('a');
Directory(dst).createSync();
c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')];
c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -278,7 +278,7 @@ void main() {
..createSync(recursive: true)
..writeAsStringSync('a');
Directory(dst).createSync();
c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'robocopy', options: '/E')];
c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'robocopy', options: '/E')];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -297,11 +297,11 @@ void main() {
testWidgets('237: 모니터링 카드 실패 글은 예외 이름 없이, 원문 (명령 줄 포함) 은 [자세히] 에서 보고 복사', (tester) async {
const raw = 'ProcessException: 원본 파일을 지우지 못했습니다\n Command: rsync -avPog --remove-source-files a/ b/';
c.settings.copyTasks = [
c.settings.copy.tasks = [
const CopyTask(id: '1', sources: ['a'], dest: 'b', method: 'rsync', options: '-avPog', move: true)
.copyWith(lastResult: 'failed', lastRun: DateTime(2026, 10, 12, 9).toIso8601String(), lastMessage: '원본 파일을 지우지 못했습니다', lastDetail: raw),
];
expect(CopyTask.fromJson(c.settings.copyTasks.single.toJson()).lastDetail, raw);
expect(CopyTask.fromJson(c.settings.copy.tasks.single.toJson()).lastDetail, raw);
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -339,7 +339,7 @@ void main() {
final src = p.join(tmp.path, 'mv243'), dst = p.join(tmp.path, 'mv243d');
Directory(src).createSync();
Directory(dst).createSync();
c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')];
c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -350,12 +350,12 @@ void main() {
expect(find.textContaining('실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤)'), findsOneWidget);
await tester.tap(find.text('취소').last);
await tester.pumpAndSettle();
expect(c.settings.liveSyncPairs, isEmpty, reason: '취소하면 옮기지 않음');
expect(c.settings.copy.liveSyncPairs, isEmpty, reason: '취소하면 옮기지 않음');
await tester.tap(find.text('lsync 로 이동').first);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '옮기기'));
await tester.pumpAndSettle();
expect(c.settings.liveSyncPairs.map((x) => x.source), [src]);
expect(c.settings.copy.liveSyncPairs.map((x) => x.source), [src]);
});
testWidgets('105: 원본이 대상 안 + 창에서 [원본 파일 지우기] 로 지우기 옵션이 생기면 막는다', (tester) async {
@ -365,9 +365,9 @@ void main() {
..writeAsStringSync('n');
final center = CopyCenter.of(c);
c.settings
..rsyncOptions = '-avPog'
..copy.rsyncOptions = '-avPog'
..explorer.layout = 'single'
..rsyncPaths = [outer, tmp.path];
..copy.rsyncPaths = [outer, tmp.path];
// 이동 (원본 파일 지우기) 작업만 --delete 를 기억
await center.remember([inner], outer, contents: true, method: 'rsync', move: true).then((t) => center.update(t.copyWith(options: '-avPog --delete')));
tester.view.physicalSize = const Size(1600, 900);
@ -404,7 +404,7 @@ void main() {
final a = p.join(tmp.path, 'ca'), b = p.join(tmp.path, 'cb');
Directory(a).createSync(recursive: true);
Directory(b).createSync(recursive: true);
c.settings.copyTasks = [
c.settings.copy.tasks = [
CopyTask(id: '1', sources: [a], dest: b, contents: true, method: 'rsync', options: '-avPog --remove-source-files'),
CopyTask(id: '2', sources: [b], dest: a, contents: true, move: true, method: 'rsync', options: '-avPog'),
];
@ -427,7 +427,7 @@ void main() {
File(p.join(dst, 'keep.txt'))
..createSync(recursive: true)
..writeAsStringSync('k');
c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av')];
c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av')];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -441,7 +441,7 @@ void main() {
expect(find.text('고친 옵션을 저장하지 않았습니다'), findsOneWidget);
await tester.tap(find.text('저장하고 실행'));
await tester.pump(const Duration(milliseconds: 300));
expect(c.settings.copyTasks.single.options, '-av --delete');
expect(c.settings.copy.tasks.single.options, '-av --delete');
// 저장한 옵션에 지우기가 있으므로 미리 보기 창
expect(find.text('지우기가 들어 있는 작업입니다'), findsOneWidget);
await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200);

View File

@ -158,8 +158,8 @@ void main() {
testWidgets('178 보충: 설정의 원본 지우기 옵션은 복사 창에서 빠진 까닭을 알린다 (이동 창에는 없음)', (tester) async {
c.settings
..copyMethodFile = 'robocopy'
..robocopyOptions = '/E /MOV';
..copy.methodFile = 'robocopy'
..copy.robocopyOptions = '/E /MOV';
await open(tester);
await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 15);

View File

@ -103,7 +103,7 @@ void main() {
});
testWidgets('48 보충: 환경 설정이 "늘 덮어쓰기" 면 묻지 않고 (확인 창에 알림만) 덮어쓴다', (tester) async {
c.settings.copyConflict = 'overwrite';
c.settings.copy.conflict = 'overwrite';
await startCopy(tester);
expect(find.text('덮어쓰기 (원래 파일은 없어집니다)'), findsNothing, reason: '고르는 칸 없음');
expect(find.textContaining('환경 설정대로 덮어씁니다'), findsOneWidget);
@ -121,7 +121,7 @@ void main() {
File(p.join(right, 'D', 'same.txt'))
..createSync(recursive: true)
..writeAsStringSync('NEWER');
c.settings.copyConflict = 'overwrite';
c.settings.copy.conflict = 'overwrite';
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -167,7 +167,7 @@ void main() {
testWidgets('52: 복사 중에 화면을 떠났다 돌아와도 진행 막대가 다시 보이고, 끝나면 받는 폴더를 새로 읽는다 (.jjpart 가 남아 보이지 않음)', (tester) async {
File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(150 * 1024, 3));
c.settings.copyBandwidthKBps = 60; // 약 2.5초 걸리게
c.settings.copy.bandwidthKBps = 60; // 약 2.5초 걸리게
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);

View File

@ -208,7 +208,7 @@ void main() {
File(p.join(right, 'rsub', 'back.txt')).writeAsStringSync('b'); // ⇄ 비교에서 ← 로 건너갈 것
c.settings
..explorer.layout = 'single' // 파일 탐색기 배치와 상관없이 두 창
..rsyncPaths = [left, right];
..copy.rsyncPaths = [left, right];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -257,7 +257,7 @@ void main() {
expect(find.textContaining('-avPog -u'), findsNWidgets(2));
await tester.tap(find.text('취소'));
await calm(tester);
expect(c.settings.copyTasks, isEmpty);
expect(c.settings.copy.tasks, isEmpty);
expect(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')), findsNothing); // 양쪽에는 없음
// → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움)
await act(tester, () => tester.tap(find.text('좌 → 우')));
@ -276,7 +276,7 @@ void main() {
await tester.tap(find.text('취소'));
await calm(tester);
// 고른 폴더는 기억 (다음에 열 때 그 폴더부터)
expect(c.settings.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]);
expect(c.settings.copy.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]);
// 새 폴더: 만든 폴더가 바로 그 창의 원본 · 대상으로 골라진다 (오른쪽 창 rsub 안에)
await act(tester, () => tester.tap(find.text('rsub')));

View File

@ -152,7 +152,7 @@ void main() {
File(p.join(left, 'e.txt')).writeAsStringSync('e');
c.settings
..explorer.dropAction = 'move'
..copyConflict = 'overwrite';
..copy.conflict = 'overwrite';
await open(tester);
// 전체 시험 중 (부하) 에는 목록을 늦게 읽어 e.txt 를 못 찾았다 - 두 파일이 보일 때까지
await settle(tester, () => find.text('e.txt').evaluate().isNotEmpty && find.text('doc.txt').evaluate().isNotEmpty);
@ -178,7 +178,7 @@ void main() {
..writeAsStringSync('mine');
c.settings
..explorer.dropAction = 'move'
..copyConflict = 'overwrite';
..copy.conflict = 'overwrite';
await open(tester);
await act(tester, () => tester.tap(find.text('선택').first));
await act(tester, () => tester.tap(markOf('doc.txt')));

View File

@ -92,7 +92,7 @@ void main() {
test('Rsync 실행: 막는 이유 · 쌍 · ⇄ 의 옵션 (← 는 지우지 않음 · -u) · 원본 지우기 · 미리 보기 (실제 폴더)', () async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.rsyncOptions = '-a --delete';
c.settings.copy.rsyncOptions = '-a --delete';
final center = CopyCenter.of(c);
final l = p.join(tmp.path, 'L'), r = p.join(tmp.path, 'R');
Directory(l).createSync();

View File

@ -30,7 +30,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorer.paths = [left, right]
..copyBandwidthKBps = 512; // 큰 파일이 6초쯤 걸리게
..copy.bandwidthKBps = 512; // 큰 파일이 6초쯤 걸리게
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -70,7 +70,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorer.orientation = 'side'
..rsyncPaths = [l, r];
..copy.rsyncPaths = [l, r];
tester.view.physicalSize = const Size(400, 860);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -110,7 +110,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorer.orientation = 'side'
..rsyncPaths = [l, r];
..copy.rsyncPaths = [l, r];
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -146,7 +146,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorer.orientation = 'side'
..rsyncPaths = [l, r];
..copy.rsyncPaths = [l, r];
tester.view.physicalSize = const Size(860, 400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -168,7 +168,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorer.orientation = 'side'
..rsyncPaths = [p.join(inner, 'L'), p.join(sd, 'R')];
..copy.rsyncPaths = [p.join(inner, 'L'), p.join(sd, 'R')];
tester.view.physicalSize = const Size(412, 880);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -194,7 +194,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorer.layout = 'single'
..rsyncPaths = [l, r];
..copy.rsyncPaths = [l, r];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);

View File

@ -185,7 +185,7 @@ void main() {
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
c.settings.rsyncPaths = [left, right];
c.settings.copy.rsyncPaths = [left, right];
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first));
@ -203,9 +203,9 @@ void main() {
await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first));
await calm(tester);
await act(tester, () => tester.tap(find.text('거꾸로')));
await settle(tester, () => c.settings.rsyncPaths.length == 2 && c.settings.rsyncPaths[0] == right && c.settings.rsyncPaths[1] == left,
await settle(tester, () => c.settings.copy.rsyncPaths.length == 2 && c.settings.copy.rsyncPaths[0] == right && c.settings.copy.rsyncPaths[1] == left,
rounds: 100);
expect(c.settings.rsyncPaths, [right, left]);
expect(c.settings.copy.rsyncPaths, [right, left]);
});
testWidgets('74: 고르기 도구 - 개수 · 합계 크기, 반전, 패턴, 같은 확장자, 범위 (Shift · 길게 누르기)', (tester) async {

View File

@ -38,8 +38,8 @@ void main() {
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: NoopShell()));
c.settings
..explorer.paths = [left, right]
..copyMethodFile = 'builtin'
..copyMethodFolder = 'builtin';
..copy.methodFile = 'builtin'
..copy.methodFolder = 'builtin';
});
tearDown(() async {
for (var i = 0;; i++) {

View File

@ -122,8 +122,8 @@ void main() {
final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass');
c.settings
..webdavServers = [s]
..rsyncOptions = '-av --remove-source-files'
..rsyncPaths = [left, 'dav://nas/'];
..copy.rsyncOptions = '-av --remove-source-files'
..copy.rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]);
// 1280×720 (낮은 창) 에서도 체크 · 경고가 버튼에 가려지지 않게
await pump(tester, ExplorerPage(device: device, c: c, rsync: true), size: const Size(1280, 720));
@ -193,7 +193,7 @@ void main() {
final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass');
c.settings
..webdavServers = [s]
..rsyncPaths = [left, 'dav://nas/'];
..copy.rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]);
await pump(tester, ExplorerPage(device: device, c: c, rsync: true));
await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('원격').evaluate().isNotEmpty);
@ -216,6 +216,6 @@ void main() {
await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200);
expect(got.readAsStringSync(), 'inner');
expect(File(p.join(remote.path, '원격', 'note.txt')).existsSync(), isTrue); // 지우기 없이
expect(c.settings.copyTasks.single.dest, 'dav://nas/원격'); // 모니터링에 남음
expect(c.settings.copy.tasks.single.dest, 'dav://nas/원격'); // 모니터링에 남음
});
}

View File

@ -53,9 +53,9 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorer.paths = [tmp.path, tmp.path]
..rsyncPaths = [tmp.path, tmp.path]
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];
..copy.rsyncPaths = [tmp.path, tmp.path]
..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];
c.videos.add(VideoItem(p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 시험용 2026.mp4')));
c.selected = c.videos.first;
final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null);

View File

@ -5,7 +5,7 @@ import 'package:jj_mkvmaker/app/settings.dart';
/// 97: 실시간 동기화의 지우기는 쌍의 "지우기 포함" 으로만 (설정 옵션의 --delete · --del · /MIR · /PURGE 는 빼고)
void main() {
test('rsync: 설정 옵션의 지우기는 빼고, 쌍이 지우기 포함일 때만 --delete 하나', () {
final s = AppSettings()..rsyncOptions = '-avPog --delete --del --delete-after';
final s = AppSettings()..copy.rsyncOptions = '-avPog --delete --del --delete-after';
final off = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b'), s, windows: false);
expect(off.where((o) => o.startsWith('--del')), isEmpty);
final on = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false);
@ -13,7 +13,7 @@ void main() {
});
test('10/20: --delete-excluded 는 쌍이 지우기 포함일 때만 (지울지 묻는 목록과 같게)', () {
final s = AppSettings()..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
final s = AppSettings()..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
final off = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b'), s, windows: false);
expect(off.where((o) => o.startsWith('--del')), isEmpty);
final on = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false);
@ -22,7 +22,7 @@ void main() {
});
test('robocopy: /MIR 는 /E 로, /PURGE 는 쌍이 지우기 포함일 때만', () {
final s = AppSettings()..robocopyOptions = '/MIR /PURGE /R:2';
final s = AppSettings()..copy.robocopyOptions = '/MIR /PURGE /R:2';
final off = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), s).map((o) => o.toUpperCase()).toList();
expect(off, isNot(contains('/MIR')));
expect(off, isNot(contains('/PURGE')));
@ -33,8 +33,8 @@ void main() {
test('107: 원본을 지우는 옵션 (--remove-source-files · /MOV · /MOVE) 도 실시간 동기화에서는 쓰지 않는다', () {
final s = AppSettings()
..rsyncOptions = '-avPog --remove-source-files --remove-sent-files'
..robocopyOptions = '/E /MOV /MOVE';
..copy.rsyncOptions = '-avPog --remove-source-files --remove-sent-files'
..copy.robocopyOptions = '/E /MOV /MOVE';
final r = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false);
expect(r.where((o) => o.startsWith('--remove')), isEmpty);
final w = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:', r'D:', delete: true), s).map((o) => o.toUpperCase()).toList();

View File

@ -31,8 +31,8 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
// 앱에 든 rsync 를 그대로 (시험 환경에는 앱 폴더 (path_provider) 가 없어 '직접 지정' 으로)
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync;
..copy.rsyncSource = 'custom'
..copy.rsyncPath = rsync;
final bad = <String>[];
for (final opts in sceneOptions) {
writeScene(src, want);
@ -40,9 +40,9 @@ void main() {
writeScene(src, got);
final before = treeOf(got);
final gone = [for (final k in before.keys) if (!w.containsKey(k)) k];
c.settings.rsyncOptions = opts;
c.settings.copy.rsyncOptions = opts;
final pair = LiveSyncPair(src, got, method: 'rsync', delete: optionsDelete(opts));
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
final k = LiveSync.keyOf(pair);
final why = <String>[];
@ -64,7 +64,7 @@ void main() {
if (!RsyncFilter.parse(opts).deleteExcluded && excl.isNotEmpty) why.add('--delete-excluded 가 없는데 따로 묶음: $excl');
if (excl.toSet().difference(asked.toSet()).isNotEmpty) why.add('따로 묶은 것이 지울 목록에 없음: $excl');
if (excl.toSet().length != wantExcl.length || !excl.toSet().containsAll(wantExcl)) why.add('따로 묶은 것 $excl ≠ $wantExcl');
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
if (asked.isNotEmpty) await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true);
why.addAll(treeDiff(w, treeOf(got)));
if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}');
live.dispose();
@ -86,11 +86,11 @@ void main() {
}
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
..copy.rsyncSource = 'custom'
..copy.rsyncPath = rsync
..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
await tester.runAsync(() => live.syncNow(pair));
@ -130,29 +130,29 @@ void main() {
}
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-av --exclude=*.tmp';
..copy.rsyncSource = 'custom'
..copy.rsyncPath = rsync
..copy.rsyncOptions = '-av --exclude=*.tmp';
// 이미 이 옵션으로 확인한 쌍
c.settings.liveSyncPairs = [
c.settings.copy.liveSyncPairs = [
LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'),
];
final live = LiveSync(c);
addTearDown(live.dispose);
LiveSyncPair pair() => c.settings.liveSyncPairs.single;
LiveSyncPair pair() => c.settings.copy.liveSyncPairs.single;
final k = LiveSync.keyOf(pair());
await live.syncNow(pair());
expect(live.toDelete[k], isNull);
expect(File(p.join(dst, 'a.txt')).existsSync(), isTrue);
// ① 지울 것이 늘지 않는 바꿈 (-h 더하기) → 묻지 않고 지금 옵션을 기억
c.settings.rsyncOptions = '-avh --exclude=*.tmp';
c.settings.copy.rsyncOptions = '-avh --exclude=*.tmp';
await live.syncNow(pair());
expect(live.toDelete[k], isNull);
expect(pair().confirmedOptions, '-avh --exclude=*.tmp');
// ② --delete-excluded 를 더함 → 거른 것이 새로 지워지므로 다시 묻고, 확인 전에는 지우지 않음
c.settings.rsyncOptions = '-avh --exclude=*.tmp --delete-excluded';
c.settings.copy.rsyncOptions = '-avh --exclude=*.tmp --delete-excluded';
await live.syncNow(pair());
expect(live.toDelete[k], unorderedEquals(['only.tmp', 'keep']));
expect(live.reasked, contains(k));
@ -167,7 +167,7 @@ void main() {
expect(live.reasked, isNot(contains(k)));
// ③ 거르기를 빼면 (--exclude 를 지움) 지울 것은 줄거나 같다 → 묻지 않음
c.settings.rsyncOptions = '-avh';
c.settings.copy.rsyncOptions = '-avh';
await live.syncNow(pair());
expect(live.toDelete[k], isNull);
expect(pair().confirmedOptions, '-avh');
@ -175,15 +175,15 @@ void main() {
// ④ 예전에 확인한 쌍 (기억한 옵션 없음): --delete-excluded 로 새로 지워질 것이 있으면 묻는다 · 없으면 기억만
File(p.join(dst, 'new.tmp')).writeAsStringSync('n');
c.settings
..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'
..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})];
..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded'
..copy.liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})];
expect(pair().confirmedOptions, isNull);
await live.syncNow(pair());
expect(live.toDelete[k], ['new.tmp']);
expect(File(p.join(dst, 'new.tmp')).existsSync(), isTrue);
c.settings
..rsyncOptions = '-av --exclude=*.tmp'
..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})];
..copy.rsyncOptions = '-av --exclude=*.tmp'
..copy.liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})];
await live.syncNow(pair());
expect(live.toDelete[k], isNull);
expect(pair().confirmedOptions, '-av --exclude=*.tmp');
@ -219,8 +219,8 @@ void main() {
}
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = sceneRsync ?? '';
..copy.rsyncSource = 'custom'
..copy.rsyncPath = sceneRsync ?? '';
});
tearDown(() => tmp.deleteSync(recursive: true));
bool there(String rel) => File(p.join(dst, rel)).existsSync();
@ -248,14 +248,14 @@ void main() {
expect(r.exitCode, 0, reason: '${r.stderr}');
expect(File(p.join(probe, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움');
c.settings.rsyncOptions = opts;
c.settings.copy.rsyncOptions = opts;
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: confirmed, confirmedOptions: confirmed ? opts : null);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
final k = LiveSync.keyOf(pair);
for (var i = 0; i < 2; i++) {
await live.syncNow(c.settings.liveSyncPairs.single);
await live.syncNow(c.settings.copy.liveSyncPairs.single);
expect(live.status[k]!.$2, isNot(contains('실패')));
expect([there('a.txt'), there('old.log'), there('extra.txt')], [true, true, true], reason: '자동 맞추기는 지우지 않음 ($i)');
expect(there('b.txt') && there('c.log'), isTrue, reason: '보내기는 함');
@ -264,13 +264,13 @@ void main() {
expect(live.pending[k]!.where((d) => d.startsWith('− ')), isEmpty, reason: '− 줄이 늘 남지 않음');
}
// [지금 지우기 (확인)] - 창에 보일 목록은 미리 본 것 (a.txt 는 없음 - 그래서 빨간 경고와 체크)
final (list, _) = await live.previewDeletes(c.settings.liveSyncPairs.single);
final (list, _) = await live.previewDeletes(c.settings.copy.liveSyncPairs.single);
expect(list, unorderedEquals(['old.log', 'extra.txt']));
await live.deleteNow(c.settings.liveSyncPairs.single);
await live.deleteNow(c.settings.copy.liveSyncPairs.single);
expect([there('old.log'), there('extra.txt')], [false, false]);
// 다음 자동 맞추기는 다시 보내기만
File(p.join(dst, 'extra2.txt')).writeAsStringSync('x');
await live.syncNow(c.settings.liveSyncPairs.single);
await live.syncNow(c.settings.copy.liveSyncPairs.single);
expect(there('extra2.txt'), isTrue);
expect(live.noAutoDelete[k], isNotEmpty);
});
@ -279,19 +279,19 @@ void main() {
test('관리자 지시 (10/20): P (지키기) 는 이제 따른다 - 묻는 목록에 지킨 것 없음 · 확인 뒤 남음 · 다시 묻지 않음', () async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --filter="P old.log"';
c.settings.copy.rsyncOptions = '-a --filter="P old.log"';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
final k = LiveSync.keyOf(pair);
await live.syncNow(pair);
expect(live.noAutoDelete[k], isNull);
expect(live.toDelete[k], ['extra.txt'], reason: 'old.log 는 지킴');
await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true);
expect((there('old.log'), there('extra.txt')), (true, false));
for (var i = 0; i < 2; i++) {
await live.syncNow(c.settings.liveSyncPairs.single);
await live.syncNow(c.settings.copy.liveSyncPairs.single);
expect(live.toDelete[k], isNull, reason: '다시 묻지 않음 ($i)');
expect(live.pending[k], isEmpty);
}
@ -299,9 +299,9 @@ void main() {
test('미리 볼 수 있는 규칙만이면 예전처럼 (자동 지우기 · 묻기)', () async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --exclude=*.log';
c.settings.copy.rsyncOptions = '-a --exclude=*.log';
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
await live.syncNow(pair);
@ -312,9 +312,9 @@ void main() {
testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async {
if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음');
c.settings.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로
c.settings.copy.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
late BuildContext ctx;

View File

@ -50,7 +50,7 @@ void main() {
() async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, '원본을 읽을 수 없어 멈춤');
@ -65,7 +65,7 @@ void main() {
..writeAsStringSync('a');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(src, dst, delete: true); // 새 쌍: deleteConfirmed false
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
await live.syncNow(pair);
final k = LiveSync.keyOf(pair);
@ -74,8 +74,8 @@ void main() {
expect(live.toDelete[k], unorderedEquals(['keep1.txt', 'sub']));
expect(live.status[k]!.$2, contains('확인 필요'));
// 지우기 포함으로 맞추기 (확인)
await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
expect(c.settings.liveSyncPairs.single.deleteConfirmed, isTrue);
await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true);
expect(c.settings.copy.liveSyncPairs.single.deleteConfirmed, isTrue);
expect(File(p.join(dst, 'keep1.txt')).existsSync(), isFalse);
expect(live.toDelete[k], isNull);
live.dispose();

View File

@ -54,10 +54,10 @@ void main() {
final a = LiveSyncPair(p.join(tmp.path, 'a'), p.join(tmp.path, 'A'));
final b = LiveSyncPair(p.join(tmp.path, 'b'), p.join(tmp.path, 'B'));
final d = LiveSyncPair(p.join(tmp.path, 'd'), p.join(tmp.path, 'D'));
c.settings.liveSyncPairs = [a, b, d];
c.settings.copy.liveSyncPairs = [a, b, d];
await replaceLiveSyncPair(c, b, b.copyWith(enabled: false));
expect(c.settings.liveSyncPairs.map((x) => x.source), [a.source, b.source, d.source]);
expect(c.settings.liveSyncPairs.map((x) => x.enabled), [true, false, true]);
expect(c.settings.copy.liveSyncPairs.map((x) => x.source), [a.source, b.source, d.source]);
expect(c.settings.copy.liveSyncPairs.map((x) => x.enabled), [true, false, true]);
});
testWidgets('copy_task_card: 작업이 없으면 안내, 있으면 작업마다 카드', (tester) async {
@ -76,7 +76,7 @@ void main() {
testWidgets('live_pair_card: 화면 일은 부르는 쪽 콜백으로 (최종 정리 · 복사 목록으로)', (tester) async {
final c = _controller();
final x = LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), method: 'builtin');
c.settings.liveSyncPairs = [x];
c.settings.copy.liveSyncPairs = [x];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -95,12 +95,12 @@ void main() {
// 스위치는 replaceLiveSyncPair 로 저장
await tester.tap(find.byType(Switch));
await tester.pump();
expect(c.settings.liveSyncPairs.single.enabled, isFalse);
expect(c.settings.copy.liveSyncPairs.single.enabled, isFalse);
// 복사 목록으로 옮기면 lsync 쌍은 빠지고 복사 작업이 생긴 뒤 부르는 쪽 콜백
await tester.tap(find.text('복사 · rsync 로 이동'));
await tester.pumpAndSettle();
expect(shown, 1);
expect(c.settings.liveSyncPairs, isEmpty);
expect(c.settings.copyTasks.single.dest, x.target);
expect(c.settings.copy.liveSyncPairs, isEmpty);
expect(c.settings.copy.tasks.single.dest, x.target);
});
}

View File

@ -88,7 +88,7 @@ void main() {
final single = File(p.join(tmp.path, 'single.txt'))..writeAsStringSync('s');
final center = CopyCenter(c);
final t = CopyTask(id: '9', sources: [src, single.path], dest: dst, move: true, method: 'rsync', prune: 'all', once: true);
await c.updateSettings((x) => x.copyTasks = [t]);
await c.updateSettings((x) => x.copy.tasks = [t]);
expect(await center.toLiveSync(center.tasks.single), 1);
final rest = center.tasks.single;
expect(rest.sources, [single.path]);
@ -96,7 +96,7 @@ void main() {
});
test('같은 복사는 기억한 옵션 · 실행 결과 기록 · lsync 로 이동 · 다시 복사 목록으로', () async {
c.settings.copyMethodFolder = 'builtin'; // rsync 실행은 sync_tools_test (시험 환경에서는 path_provider 없음)
c.settings.copy.methodFolder = 'builtin'; // rsync 실행은 sync_tools_test (시험 환경에서는 path_provider 없음)
final center = CopyCenter(c);
final t = await center.remember([src], dst);
await center.update(t.copyWith(options: '-a --custom', bandwidthKBps: 77));
@ -114,12 +114,12 @@ void main() {
// lsync 로: 원본 → 대상\이름
expect(await center.toLiveSync(center.tasks.single), 1);
expect(center.tasks, isEmpty);
final pair = c.settings.liveSyncPairs.single;
final pair = c.settings.copy.liveSyncPairs.single;
expect([pair.source, pair.target], [src, p.join(dst, 'src')]);
// 다시 복사 목록으로 (안의 것 → 대상)
final back = await center.fromLiveSync(pair);
expect(c.settings.liveSyncPairs, isEmpty);
expect(c.settings.copy.liveSyncPairs, isEmpty);
expect([back.contents, back.dest], [true, p.join(dst, 'src')]);
expect(back.method, Platform.isWindows ? 'rsync' : 'builtin'); // rsync 로 옮겨 온다 (쓸 수 있으면)
File(p.join(src, 'new.txt')).writeAsStringSync('n');
@ -139,7 +139,7 @@ void main() {
});
test('기억한 복사의 원본이 없어졌거나 자기 안으로면 시작하지 않고 실패로 남긴다', () async {
c.settings.copyMethodFolder = 'rsync';
c.settings.copy.methodFolder = 'rsync';
final center = CopyCenter(c);
final t = await center.remember([src], src); // 자기 안으로
expect(await center.start(t), isNull);
@ -157,7 +157,7 @@ void main() {
final pair = LiveSyncPair(src, target, schedule: ['0 $off * * *'], delete: true);
expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt']));
expect(LiveSync.activeNow(pair), isFalse);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c)..start();
// 모아서 3초 뒤 (컴퓨터가 바쁘면 늦을 수 있어 정해진 4초가 아니라 될 때까지 - 최대 20초)
for (var i = 0; i < 80 && (live.pending[LiveSync.keyOf(pair)]?.length ?? 0) < 2; i++) {
@ -191,10 +191,10 @@ void main() {
test('설정 저장 · 읽기, Windows 는 종료 동작과 같은 값', () {
final s = AppSettings()
..runInBackground = true
..liveSyncOnStart = 'ask';
..copy.liveSyncOnStart = 'ask';
final r = AppSettings.fromJson(s.toJson());
expect([r.runInBackground, r.liveSyncOnStart], [true, 'ask']);
expect(AppSettings.fromJson({'liveSyncOnStart': 'x'}).liveSyncOnStart, 'auto');
expect([r.runInBackground, r.copy.liveSyncOnStart], [true, 'ask']);
expect(AppSettings.fromJson({'liveSyncOnStart': 'x'}).copy.liveSyncOnStart, 'auto');
// 사용자 결정 (10/8): Android 백그라운드로 실행은 기본 켜짐. 사용자가 끈 값 (새 형식) 은 그대로
expect(AppSettings.fromJson({}).runInBackground, isTrue);
expect(AppSettings.fromJson({'runInBackground': false, 'backgroundV2': true}).runInBackground, isFalse);
@ -218,9 +218,9 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pa = LiveSyncPair(a.path, p.join(tmp.path, 'ta')), pb = LiveSyncPair(b.path, p.join(tmp.path, 'tb'));
c.settings
..liveSyncPairs = [pa, pb]
..liveSyncOnStart = 'off';
final live = LiveSync(c)..start(hold: c.settings.liveSyncOnStart != 'auto');
..copy.liveSyncPairs = [pa, pb]
..copy.liveSyncOnStart = 'off';
final live = LiveSync(c)..start(hold: c.settings.copy.liveSyncOnStart != 'auto');
// 맞추기 (rsync 프로세스) 가 끝난 뒤 닫고 지운다 - 도는 중에 지우면 "다른 프로세스가 사용 중" (전체 시험이 무거울 때)
addTearDown(() async {
for (var i = 0; i < 200 && live.anyRunning; i++) {
@ -264,7 +264,7 @@ void main() {
Directory(p.join(t.path, 'onlydir')).createSync();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(a.path, t.path, method: 'builtin');
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
addTearDown(live.dispose);
await live.refreshPending(pair);
@ -283,7 +283,7 @@ void main() {
File(p.join(a.path, '1.txt')).writeAsStringSync('1');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(a.path, p.join(tmp.path, 't'), method: 'builtin');
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
final running = live.syncNow(pair);
live.dispose();
@ -297,8 +297,8 @@ void main() {
addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'builtin')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), schedule: const ['0 9-17 * * 1-5'])];
..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'builtin')]
..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), schedule: const ['0 9-17 * * 1-5'])];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -318,7 +318,7 @@ void main() {
expect(find.text('설정 반영 후 실행'), findsOneWidget);
await tester.tap(find.text('현재 유지'));
await tester.pumpAndSettle();
expect(c.settings.copyTasks.single.bandwidthKBps, 500);
expect(c.settings.copy.tasks.single.bandwidthKBps, 500);
expect(CopyCenter.of(c).jobs, isEmpty);
// lsync 탭: 일정 요약 · 맞추기 버튼
@ -333,7 +333,7 @@ void main() {
final tmp = Directory.systemTemp.createTempSync('jj_monbad_');
addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'rsync', options: '-av delete')];
c.settings.copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'rsync', options: '-av delete')];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -353,7 +353,7 @@ void main() {
final tmp = Directory.systemTemp.createTempSync('jj_mon233_');
addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), move: true, method: 'builtin')];
c.settings.copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), move: true, method: 'builtin')];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -372,6 +372,6 @@ void main() {
expect(find.text('받는 쪽이 더 새것이면 건너뛰기'), findsOneWidget);
await tester.tap(find.text('받는 쪽이 더 새것이면 건너뛰기'));
await tester.pump();
expect(c.settings.copySkipNewer, isFalse);
expect(c.settings.copy.skipNewer, isFalse);
});
}

View File

@ -182,8 +182,8 @@ void main() {
});
test('229: 앱 직접 이동 - 받는 쪽이 더 새것이면 덮어쓰지 않고 원본도 남긴다 (환경 설정 처음 값 켜짐)', () async {
expect(AppSettings.fromJson({}).copySkipNewer, isTrue);
expect(AppSettings.fromJson((AppSettings()..copySkipNewer = false).toJson()).copySkipNewer, isFalse);
expect(AppSettings.fromJson({}).copy.skipNewer, isTrue);
expect(AppSettings.fromJson((AppSettings()..copy.skipNewer = false).toJson()).copy.skipNewer, isFalse);
final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src');
old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2)));
File(p.join(dst, 'older.txt')).writeAsStringSync('NEW version in dst');

View File

@ -225,14 +225,14 @@ void main() {
final (s, d) = scene('ls', 'ld', ['a.txt'], ['old.txt']);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = '-a'
..liveSyncPairs = [LiveSyncPair(unc(s), unc(d), method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a')];
..copy.rsyncSource = 'custom'
..copy.rsyncPath = rsync
..copy.rsyncOptions = '-a'
..copy.liveSyncPairs = [LiveSyncPair(unc(s), unc(d), method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a')];
final live = LiveSync(c);
addTearDown(live.dispose);
await live.syncNow(c.settings.liveSyncPairs.single);
expect(live.status[LiveSync.keyOf(c.settings.liveSyncPairs.single)]!.$2, isNot(contains('실패')));
await live.syncNow(c.settings.copy.liveSyncPairs.single);
expect(live.status[LiveSync.keyOf(c.settings.copy.liveSyncPairs.single)]!.$2, isNot(contains('실패')));
expect(treeOf(d).keys.toSet(), {'a.txt'});
}, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)');
@ -241,13 +241,13 @@ void main() {
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"')];
..copy.rsyncSource = 'custom'
..copy.rsyncPath = rsync
..copy.rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"'
..copy.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;
final pair = c.settings.copy.liveSyncPairs.single;
await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
final t = treeOf(d);

View File

@ -159,15 +159,15 @@ void main() {
// ③ · ④ 실시간 동기화 (새 쌍 - 지울 것이 있으면 묻고, 확인하면 맞춤)
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rsyncSource = 'custom'
..rsyncPath = rsync
..rsyncOptions = opts
..copy.rsyncSource = 'custom'
..copy.rsyncPath = rsync
..copy.rsyncOptions = opts
..webdavServers = [dav()];
for (final (label, target, local) in [('실시간 (로컬)', p.join(tmp.path, 'live'), true), ('실시간 (원격)', 'dav://$id/l', false)]) {
final dir = local ? target : p.join(remote.path, 'l');
writeScene(src, dir, srcFiles: _src, dstFiles: _dst);
final pair = LiveSyncPair(src, target, method: 'rsync', delete: delete);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
final k = LiveSync.keyOf(pair);
await live.syncNow(pair);
@ -180,7 +180,7 @@ void main() {
for (final a in asked) {
if (!gone.contains(a)) why.add('$label: rsync 는 지우지 않는데 물음: $a');
}
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
if (asked.isNotEmpty) await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true);
for (final d in treeDiff(w, treeOf(dir))) {
why.add('$label: $d');
}

View File

@ -39,7 +39,7 @@ void main() {
c.settings
..webdavServers = [s]
..explorer.layout = 'single'
..rsyncPaths = [left, 'dav://nas/'];
..copy.rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]);
});
tearDown(() async {
@ -68,7 +68,7 @@ void main() {
testWidgets('100 · 95: WebDAV ⇄ + --delete - 미리 보기대로 → (지움) 다음 ← (지우지 않음), 한쪽에만 있던 새 파일이 양쪽에서 사라지지 않는다',
(tester) async {
c.settings.rsyncOptions = '-avPog --delete';
c.settings.copy.rsyncOptions = '-avPog --delete';
await open(tester);
await act(tester, () => tester.tap(find.text('좌 ⇄ 우')));
await waitCompare(tester);
@ -92,7 +92,7 @@ void main() {
testWidgets('96: 원본 파일 지우기를 켜면 확인 창 · 미리 보기가 실제 실행할 (이동) 작업의 옵션으로 바뀐다', (tester) async {
// 기억해 둔 (복사) 작업은 --delete 없음, 새 작업의 기본 옵션은 --delete 있음
c.settings.rsyncOptions = '-avPog --delete';
c.settings.copy.rsyncOptions = '-avPog --delete';
await open(tester);
await CopyCenter.of(c)
.remember([p.join(left, 'sub')], 'dav://nas/rsub', contents: true, method: 'rsync')
@ -115,7 +115,7 @@ void main() {
});
testWidgets('97: [다시 비교] 하는 동안은 지우는 실행을 누를 수 없다', (tester) async {
c.settings.rsyncOptions = '-avPog --del';
c.settings.copy.rsyncOptions = '-avPog --del';
await open(tester);
await act(tester, () => tester.tap(find.text('좌 → 우')));
await waitCompare(tester);
@ -150,13 +150,13 @@ void main() {
await settle(tester, () => false, rounds: 60);
expect(File(p.join(remote.path, 'rsub', 'a.txt')).existsSync(), isFalse);
expect(File(p.join(left, 'sub', 'b.txt')).existsSync(), isFalse);
expect(c.settings.copyTasks, isEmpty);
expect(c.settings.copy.tasks, isEmpty);
await tester.pump(const Duration(seconds: 20));
});
}
testWidgets('110: 좁은 화면 - 비교가 끝나도 [취소] · [실행] 자리가 그대로, 창을 여러 번 열고 닫아도 실행되지 않음', (tester) async {
c.settings.rsyncOptions = '-avPog --delete';
c.settings.copy.rsyncOptions = '-avPog --delete';
HttpOverrides.global = null;
tester.view.physicalSize = const Size(900, 1400); // 세로로 든 태블릿
tester.view.devicePixelRatio = 1;

View File

@ -67,12 +67,12 @@ void main() {
test('124-1: 지우려는 서버를 쓰는 실시간 동기화 · 기억된 작업을 찾는다', () {
final s = AppSettings()
..liveSyncPairs = [
..copy.liveSyncPairs = [
LiveSyncPair(r'C:\영상', 'dav://nas/백업'),
LiveSyncPair(r'C:\a', r'D:\b'),
LiveSyncPair('dav://other/x', r'C:\y'),
]
..copyTasks = [
..copy.tasks = [
CopyTask(id: 't', sources: const ['dav://nas/사진'], dest: r'C:\사진', method: 'builtin', options: '', once: false),
];
final users = davServerUsers(s, 'nas');

View File

@ -12,7 +12,7 @@ import 'package:jj_mkvmaker/ui/settings_page.dart';
void main() {
testWidgets('213: 기본값 ("-avPog") 을 지나며 쳐도 칸이 그대로 (포커스 · 입력 이어짐) · ↺ 는 칸 안의 글도 되돌린다', (tester) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.rsyncOptions = '-avPo';
c.settings.copy.rsyncOptions = '-avPo';
tester.view.physicalSize = const Size(1400, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -36,11 +36,11 @@ void main() {
expect(tester.state(find.byType(TextFormField).first), same(first), reason: '칸을 새로 만들지 않음 ($t)');
expect(tester.testTextInput.hasAnyClients, isTrue, reason: '포커스 그대로 ($t)');
}
expect(c.settings.rsyncOptions, '-avPog -');
expect(c.settings.copy.rsyncOptions, '-avPog -');
// ↺: 칸 안의 글도 기본값으로
await tester.tap(find.byTooltip('기본값으로 ($defaultRsyncOptions)').first);
await tester.pump();
expect(c.settings.rsyncOptions, defaultRsyncOptions);
expect(c.settings.copy.rsyncOptions, defaultRsyncOptions);
expect(find.text(defaultRsyncOptions), findsWidgets);
});

View File

@ -25,7 +25,7 @@ void main() {
});
test('WebDAV 가 끼면 탐색기 복사 확인 창에 robocopy 가 아니라 실제 방법 (현재 방식)', () {
c.settings.copyMethodFolder = 'robocopy';
c.settings.copy.methodFolder = 'robocopy';
final t = CopyCenter.of(c).fresh(['dav://nas/a'], r'C:\dst');
expect(CopyMethod.of(t.method), CopyMethod.builtin);
// Rsync 화면 (방법을 정해서) 은 그대로

View File

@ -39,7 +39,7 @@ void main() {
addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(p.join(tmp.path, 'gone'), p.join(tmp.path, 'dst'));
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
final shell = _Shell();
var opened = 0;

View File

@ -40,7 +40,7 @@ void main() {
test('68: 원본을 읽을 수 없으면 문제로 기억 (짧은 원인 · 원문 예외 없음) · "맞출 것 없음" 이 아님 · 작업 알림 맨 앞', () async {
final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
final k = LiveSync.keyOf(pair);
await live.refreshPending(pair);
@ -64,12 +64,12 @@ void main() {
test('258: 멈춘 쌍을 지우면 "동기화 멈춤" 도 바로 사라진다 (앱을 다시 켜지 않아도)', () async {
final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c)..start();
final k = LiveSync.keyOf(pair);
await live.syncNow(pair);
expect(live.problems[k], isNotNull);
await c.updateSettings((s) => s.liveSyncPairs = []);
await c.updateSettings((s) => s.copy.liveSyncPairs = []);
expect(live.problems, isEmpty);
live.dispose();
});
@ -77,7 +77,7 @@ void main() {
test('70: 원본이 정말 비었으면 멈추고 지울 목록 → [그래도 맞추기] 로 그 한 번만 지운다', () async {
Directory(src).createSync();
final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
final k = LiveSync.keyOf(pair);
await live.syncNow(pair);
@ -104,7 +104,7 @@ void main() {
final live = LiveSync(c);
// 안쪽 → 바깥, 지우기 없음: 맞춘다 (다른 파일은 그대로)
final ok = LiveSyncPair(inner, outer);
c.settings.liveSyncPairs = [ok];
c.settings.copy.liveSyncPairs = [ok];
await live.syncNow(ok);
expect(File(p.join(outer, 'new.txt')).existsSync(), isTrue);
expect(File(p.join(outer, 'other.txt')).existsSync(), isTrue);
@ -123,7 +123,7 @@ void main() {
(tester) async {
Directory(src).createSync();
final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
LiveSync.instance = live;
addTearDown(() {
@ -155,7 +155,7 @@ void main() {
Directory(src).createSync();
File(p.join(src, 'x.txt')).writeAsStringSync('x');
final pair = LiveSyncPair(src, dst);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
LiveSync.instance = live;
addTearDown(() {
@ -188,8 +188,8 @@ void main() {
..writeAsStringSync('r');
c.settings
..explorer.layout = 'single'
..rsyncOptions = '-avPog --delete'
..rsyncPaths = [l, r];
..copy.rsyncOptions = '-avPog --delete'
..copy.rsyncPaths = [l, r];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);

View File

@ -37,11 +37,11 @@ void main() {
});
test('파일 탐색기 복사 방법: 현재 방식 (기본) · robocopy, 예전 값 rsync 는 현재 방식으로 (rsync 는 Rsync 화면)', () {
expect([AppSettings().copyMethodFile, AppSettings().copyMethodFolder], ['builtin', 'builtin']);
expect(AppSettings.fromJson({'copyMethodFolder': 'rsync', 'copyMethodFile': 'rsync'}).copyMethodFolder, 'builtin');
expect(AppSettings.fromJson({'copyMethodFolder': 'robocopy'}).copyMethodFolder, 'robocopy');
final s = AppSettings()..rsyncPaths = ['/a', '/b'];
expect(AppSettings.fromJson(s.toJson()).rsyncPaths, ['/a', '/b']);
expect([AppSettings().copy.methodFile, AppSettings().copy.methodFolder], ['builtin', 'builtin']);
expect(AppSettings.fromJson({'copyMethodFolder': 'rsync', 'copyMethodFile': 'rsync'}).copy.methodFolder, 'builtin');
expect(AppSettings.fromJson({'copyMethodFolder': 'robocopy'}).copy.methodFolder, 'robocopy');
final s = AppSettings()..copy.rsyncPaths = ['/a', '/b'];
expect(AppSettings.fromJson(s.toJson()).copy.rsyncPaths, ['/a', '/b']);
});
test('양쪽 (⇄) rsync 는 -u 를 더한다 (이미 있으면 그대로)', () {
@ -117,7 +117,7 @@ void main() {
expect(rc('/E /SJ'), isNot(contains('/XJ')));
expect(rc('/E /xjd').where((o) => o.toUpperCase().startsWith('/XJ')), ['/xjd']);
expect([robocopySkipsLinks(['/E']), robocopySkipsLinks(['/E', '/SJ'])], [true, false]);
final live = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), AppSettings()..robocopyOptions = '/E');
final live = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), AppSettings()..copy.robocopyOptions = '/E');
expect(live, contains('/XJ'));
});
@ -277,20 +277,20 @@ void main() {
test('실시간 동기화: 지금 맞추기 (현재 방식) · 상태', () async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(src, p.join(tmp.path, 'mirror'));
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
await live.syncNow(pair);
expect(File(p.join(tmp.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bbb');
expect(live.status[LiveSync.keyOf(pair)]!.$2, contains('3'));
// 대상이 원본 안이면 거부
c.settings.liveSyncPairs = [LiveSyncPair(src, p.join(src, 'in'))];
c.settings.copy.liveSyncPairs = [LiveSyncPair(src, p.join(src, 'in'))];
live.start();
expect(live.status[LiveSync.keyOf(c.settings.liveSyncPairs.first)]!.$2, contains('안에'));
expect(live.status[LiveSync.keyOf(c.settings.copy.liveSyncPairs.first)]!.$2, contains('안에'));
live.dispose();
// 설정 저장 · 읽기
final back = AppSettings.fromJson(c.settings.toJson());
expect(back.liveSyncPairs.single.target, p.join(src, 'in'));
expect([back.copyMethodFolder, back.rsyncOptions, back.copyRunMode, back.rsyncSource],
expect(back.copy.liveSyncPairs.single.target, p.join(src, 'in'));
expect([back.copy.methodFolder, back.copy.rsyncOptions, back.copy.runMode, back.copy.rsyncSource],
['builtin', '-avPog', 'each', 'download']);
});
});

View File

@ -155,7 +155,7 @@ void main() {
// 예전에 확인한 쌍은 처음 맞출 때 지금 옵션을 기억하며 설정을 저장한다 (서버 목록도 설정에서 다시 읽음) - 시험 서버를 설정에도
c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')];
final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt']));
await live.syncNow(pair);
@ -336,10 +336,10 @@ void main() {
final before = treeOf(got);
// rsync 가 지운 것 (받는 쪽에 있다가 없어진 것)
final gone = [for (final k in before.keys) if (!w.containsKey(k)) k];
c.settings.rsyncOptions = opts;
c.settings.copy.rsyncOptions = opts;
// 새 쌍 (지우기 확인 전) - 지울 것이 있으면 지우지 않고 맞춘 뒤 [toDelete] 로 묻는다
final pair = LiveSyncPair(src, 'dav://$id/live', method: 'rsync', delete: optionsDelete(opts));
c.settings.liveSyncPairs = [pair];
c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c);
final k = LiveSync.keyOf(pair);
final why = <String>[];
@ -353,7 +353,7 @@ void main() {
for (final a in asked) {
if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a');
}
if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true);
if (asked.isNotEmpty) await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true);
why.addAll(treeDiff(w, treeOf(got)));
if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}');
live.dispose();
@ -368,18 +368,18 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]
..rsyncOptions = '-av --exclude=*.tmp'
..liveSyncPairs = [
..copy.rsyncOptions = '-av --exclude=*.tmp'
..copy.liveSyncPairs = [
LiveSyncPair(p.join(local.path, 'rq'), 'dav://$id/rqd',
method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'),
];
final live = LiveSync(c);
addTearDown(live.dispose);
LiveSyncPair pair() => c.settings.liveSyncPairs.single;
LiveSyncPair pair() => c.settings.copy.liveSyncPairs.single;
final k = LiveSync.keyOf(pair());
await live.syncNow(pair());
expect(live.toDelete[k], isNull);
c.settings.rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
c.settings.copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded';
await live.syncNow(pair());
expect(live.toDelete[k], ['only.tmp']);
expect(live.reasked, contains(k));