diff --git a/lib/app/settings.dart b/lib/app/settings.dart index fb9d579..d8b9a82 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -1,9 +1,6 @@ -import 'dart:convert'; import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import 'components.dart'; import '../core/reader_sources.dart' show defaultImageExtensions; @@ -17,241 +14,13 @@ import '../core/smb.dart'; import '../core/webdav.dart'; import '../services/image_ai.dart' show AiService; import '../services/app_shell.dart' show appIconOf; -import '../services/secret_store.dart'; -import '../l10n/tr.dart'; import '../core/remote_idle.dart'; +import 'settings/records.dart'; +import 'settings/settings_group.dart'; -/// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더 -class MoveTarget { - final String name; - final String dir; - const MoveTarget(this.name, this.dir); - - MoveTarget copyWith({String? name, String? dir}) => MoveTarget(name ?? this.name, dir ?? this.dir); - - Map toJson() => {'name': name, 'dir': dir}; - - factory MoveTarget.fromJson(Map j) => - MoveTarget((j['name'] as String?)?.trim().isNotEmpty == true ? j['name'] as String : '이동', j['dir'] as String); - - @override - bool operator ==(Object other) => other is MoveTarget && other.name == name && other.dir == dir; - - @override - int get hashCode => Object.hash(name, dir); -} - -/// 73: 파일 탐색기 즐겨찾기 (폴더). [right] 가 있으면 Rsync 화면의 "왼쪽 + 오른쪽" 짝 -class FolderBookmark { - final String name; - final String path; - final String? right; - const FolderBookmark({required this.name, required this.path, this.right}); - - bool get isPair => right != null; - - FolderBookmark copyWith({String? name}) => FolderBookmark(name: name ?? this.name, path: path, right: right); - - Map toJson() => {'name': name, 'path': path, if (right != null) 'right': right}; - - static FolderBookmark? fromJson(Object? j) => j is Map && j['path'] is String - ? FolderBookmark( - name: j['name'] is String && (j['name'] as String).trim().isNotEmpty ? j['name'] as String : j['path'] as String, - path: j['path'] as String, - right: j['right'] is String ? j['right'] as String : null) - : null; -} - -/// 실시간 동기화 한 쌍 (lsyncd 처럼: 원본 폴더를 지켜보다가 바뀌면 대상 폴더에 맞춘다) -class LiveSyncPair { - final String source; - final String target; - - /// 'builtin' · 'rsync' · 'robocopy' (CopyMethod 이름) - final String method; - - /// 원본에 없는 것을 대상에서 지우기 (rsync --delete, robocopy /PURGE) - final bool delete; - final bool enabled; - - /// 동작 시간 (cron 줄들, core/cron_window.dart). 비어 있으면 계속 (앱이 켜져 있는 동안 늘) - final List schedule; - - /// "지우기" 를 사용자가 지울 목록을 보고 확인했는지. 확인 전에는 지울 것이 있으면 지우지 않고 맞추며 확인을 기다린다 (42). - /// 이 값이 생기기 전부터 돌던 쌍은 확인한 것으로 본다. - final bool deleteConfirmed; - - /// 지우기를 확인할 때의 rsync 옵션 (rsync 쌍만, 그 밖은 ''). 확인한 뒤 옵션이 바뀌어 지울 것이 늘면 다시 묻는다 - /// (관리자 3a 판단 10/20 - --delete-excluded · --exclude · --filter 등). null = 이 값이 생기기 전에 확인한 쌍 - final String? confirmedOptions; - const LiveSyncPair(this.source, this.target, - {this.method = 'builtin', - this.delete = false, - this.enabled = true, - this.schedule = const [], - this.deleteConfirmed = false, - this.confirmedOptions}); - - /// [delete] 를 새로 켜면 확인은 다시 받는다 - LiveSyncPair copyWith( - {String? method, bool? delete, bool? enabled, List? schedule, bool? deleteConfirmed, String? confirmedOptions}) => - LiveSyncPair(source, target, - method: method ?? this.method, - delete: delete ?? this.delete, - enabled: enabled ?? this.enabled, - schedule: schedule ?? this.schedule, - deleteConfirmed: deleteConfirmed ?? (delete == true && !this.delete ? false : this.deleteConfirmed), - confirmedOptions: confirmedOptions ?? this.confirmedOptions); - - Map toJson() => { - 'source': source, - 'target': target, - 'method': method, - 'delete': delete, - 'enabled': enabled, - 'schedule': schedule, - 'deleteConfirmed': deleteConfirmed, - if (confirmedOptions != null) 'confirmedOptions': confirmedOptions, - }; - - factory LiveSyncPair.fromJson(Map j) => LiveSyncPair( - j['source'] as String, - j['target'] as String, - method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin', - delete: j['delete'] == true, - enabled: j['enabled'] != false, - schedule: [for (final x in (j['schedule'] as List?) ?? const []) '$x'], - // 이 값이 없는 예전 쌍은 이미 지우기 포함으로 돌던 것 → 확인한 것으로 - deleteConfirmed: j['deleteConfirmed'] as bool? ?? true, - confirmedOptions: j['confirmedOptions'] is String ? j['confirmedOptions'] as String : null, - ); -} - -/// 복사 · 이동 기억 (모니터링 > 복사 · rsync): 같은 원본 → 대상을 다시 복사하면 이 옵션을 쓴다. 언제든 [실행] 으로 다시. -class CopyTask { - final String id; - final List sources; - final String dest; - final bool move; - - /// 폴더 "안의 것" 을 대상에 맞추기 (lsync 에서 옮겨 온 것: 원본/ → 대상/) - final bool contents; - - /// CopyMethod 이름 · 그 방법의 옵션 · 한 번에 · 속도 제한 (KB/s) - final String method; - final String options; - final bool once; - final int bandwidthKBps; - - /// 마지막 실행: 시각 (ISO) · 결과 ('done' · 'failed' · 'cancelled' · '') · 글 · 파일 수 - final String lastRun; - final String lastResult; - final String lastMessage; - final int lastFiles; - - /// 235: 이동 뒤 원본에 남은 파일 수 (건너뜀 - 216). 카드에 "n개는 원본에 남음" 으로 - final int lastLeft; - - /// 237: 실패 원문 (예외 이름 · 명령 줄 포함) - 카드의 [자세히] 에서 보고 복사한다. 화면 글은 [lastMessage] - final String lastDetail; - - /// 이동 ([move], rsync --remove-source-files) 뒤 원본 정리: '' 그대로 · 'keep' 빈 폴더를 지우고 원본 폴더는 남김 · - /// 'all' 원본 폴더까지 (비었으면) 지움 (find 원본/ -type d -empty -delete) - final String prune; - - const CopyTask({ - required this.id, - required this.sources, - required this.dest, - this.move = false, - this.contents = false, - this.method = 'builtin', - this.options = '', - this.once = false, - this.bandwidthKBps = 0, - this.lastRun = '', - this.lastResult = '', - this.lastMessage = '', - this.lastFiles = 0, - this.lastLeft = 0, - this.lastDetail = '', - this.prune = '', - }); - - /// 같은 복사인지 (원본들 · 대상 · 이동) - String get key => '${move ? 'M' : contents ? 'S' : 'C'}|${[...sources]..sort()}|$dest'; - - CopyTask copyWith({ - String? method, - String? options, - bool? once, - int? bandwidthKBps, - bool? move, - String? lastRun, - String? lastResult, - String? lastMessage, - int? lastFiles, - int? lastLeft, - String? lastDetail, - String? prune, - }) => - CopyTask( - id: id, - sources: sources, - dest: dest, - move: move ?? this.move, - contents: contents, - method: method ?? this.method, - options: options ?? this.options, - once: once ?? this.once, - bandwidthKBps: bandwidthKBps ?? this.bandwidthKBps, - lastRun: lastRun ?? this.lastRun, - lastResult: lastResult ?? this.lastResult, - lastMessage: lastMessage ?? this.lastMessage, - lastFiles: lastFiles ?? this.lastFiles, - lastLeft: lastLeft ?? this.lastLeft, - lastDetail: lastDetail ?? this.lastDetail, - prune: prune ?? this.prune, - ); - - Map toJson() => { - 'id': id, - 'sources': sources, - 'dest': dest, - 'move': move, - 'contents': contents, - 'method': method, - 'options': options, - 'once': once, - 'bandwidthKBps': bandwidthKBps, - 'lastRun': lastRun, - 'lastResult': lastResult, - 'lastMessage': lastMessage, - 'lastFiles': lastFiles, - 'lastLeft': lastLeft, - 'lastDetail': lastDetail, - 'prune': prune, - }; - - factory CopyTask.fromJson(Map j) => CopyTask( - id: '${j['id']}', - sources: [for (final x in (j['sources'] as List?) ?? const []) '$x'], - dest: '${j['dest']}', - move: j['move'] == true, - contents: j['contents'] == true, - method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin', - options: j['options'] as String? ?? '', - once: j['once'] == true, - bandwidthKBps: (j['bandwidthKBps'] as num?)?.toInt() ?? 0, - lastRun: j['lastRun'] as String? ?? '', - lastResult: j['lastResult'] as String? ?? '', - lastMessage: j['lastMessage'] as String? ?? '', - lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0, - lastLeft: (j['lastLeft'] as num?)?.toInt() ?? 0, - lastDetail: j['lastDetail'] as String? ?? '', - prune: const ['keep', 'all'].contains(j['prune']) ? j['prune'] as String : '', - ); -} +export 'settings/records.dart'; +export 'settings/settings_group.dart'; +export 'settings/settings_store.dart'; /// 환경 설정 (settings.json 에 저장) class AppSettings { @@ -806,8 +575,12 @@ class AppSettings { AppSettings(); + /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 + List get groups => const []; + /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 - Map toJson({bool plainSecrets = false}) => { + Map toJson({bool plainSecrets = false}) { + final j = { // 이 판이 모르는 항목 (새 판이 쓴 것 등) 은 지우지 않고 그대로 (40) ...stripSecrets(extra), 'mkvOutputRoot': mkvOutputRoot, @@ -1030,7 +803,12 @@ class AppSettings { 'aiSource': aiSource, 'aiTargets': aiTargets, 'aiWhisper': aiWhisper, - }; + }; + for (final g in groups) { + g.write(j, plainSecrets: plainSecrets); + } + return j; + } /// 파일 탐색기의 복사 방법 (rsync 는 Rsync 화면으로 옮겨 예전 값 rsync 도 현재 방식으로) static String _method(Object? v) => v == 'robocopy' ? 'robocopy' : 'builtin'; @@ -1326,6 +1104,9 @@ class AppSettings { ..aiTargets = (j['aiTargets'] as List?)?.cast() ?? ['ko', 'en', 'ja'] ..aiWhisper = j['aiWhisper'] as String? ?? 'whisper-base'; s.migrated = _migrated(j, s); + for (final g in s.groups) { + g.read(j); + } s.extra = Map.of(j); return s; } @@ -1457,444 +1238,3 @@ class AppSettings { } String get aria2Dir => p.join(resolvedDownloadRoot(), 'jj_aria2'); } - -/// 설정 파일 읽기·쓰기 -/// 설정 파일 읽기 · 쓰기. -/// - 쓰기: 임시 파일에 다 쓴 뒤 바꿔치기 (쓰는 도중 앱이 끝나도 settings.json 이 깨지지 않게). -/// 바꿔치기 전의 정상 파일은 settings.json.bak 으로 남긴다. -/// - 읽기: settings.json 을 읽지 못하면 settings.json.bak 으로 되살린다. 읽지 못한 파일은 지우지 않고 -/// settings.broken-(시각).json 으로 보관한다 (다음 저장에 덮여 사라지지 않게). 무슨 일이 있었는지는 [problem]. -class SettingsStore { - final String? _path; - - /// 비밀 값 (WebDAV 비밀번호 · OpenSubtitles) 을 두는 곳. 경로를 직접 주면 (시험) 메모리 - final SecretStore secrets; - - SettingsStore([this._path, SecretStore? secrets]) - : secrets = secrets ?? (_path == null ? SecretStore.platform() : MemorySecretStore()); - - /// 안전 저장소를 읽을 수 있었는지 (못 읽었으면 저장소를 건드리지 않는다 - 지워 버리지 않게) - bool _secretsOk = true; - - /// 안전 저장소에 마지막으로 쓴 (읽은) 값 - 바뀐 것만 쓴다 - Map _known = {}; - - /// 설정 파일에 예전부터 평문으로 있던 값 (저장소 열쇠 → 값). 안전 저장소에 들어가기 전까지는 파일에서 지우지 않는다 (40-1). - /// 새로 넣은 값은 저장소에 못 쓰더라도 평문으로 쓰지 않는다 (사용자 결정) - 대신 [secretIssue] 로 알린다. - final Map _filePlain = {}; - - /// 안전 저장소 문제 (화면 위 알림 · [다시 시도]). null = 문제 없음 - final secretIssue = ValueNotifier(null); - - /// 안전 저장소의 열쇠 - static const _osKeys = { - 'openSubtitlesKey': 'os.key', - 'openSubtitlesUser': 'os.user', - 'openSubtitlesPassword': 'os.password', - }; - static String _davKey(String id) => 'dav.pw.$id'; - static String _sftpPw(String id) => 'sftp.pw.$id'; - static String _sftpKey(String id) => 'sftp.key.$id'; - static String _sftpKp(String id) => 'sftp.kp.$id'; - static String _smbPw(String id) => 'smb.pw.$id'; - static String _ftpPw(String id) => 'ftp.pw.$id'; - static String _aiKey(String id) => 'ai.key.$id'; - static const _davServers = 'dav.servers'; - - static String _os(AppSettings s, String k) => switch (k) { - 'openSubtitlesKey' => s.openSubtitlesKey, - 'openSubtitlesUser' => s.openSubtitlesUser, - _ => s.openSubtitlesPassword, - }; - - /// 지금 설정의 비밀 값 (빈 값은 넣지 않음 = 저장소에서 지움) - /// 규칙 (관리자 10/21): 새 종류의 비밀 값은 지금 꼴 ([secretForms]) 과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 로 만든다 - - /// 겹치면 그 꼴을 아는 예전 판이 저장할 때 "없어진 항목" 으로 보고 지운다. 새 꼴은 [secretForms] 에도 더한다 - static Map _secretsOf(AppSettings s) => !s.rememberPasswords ? const {} : { - for (final e in _osKeys.entries) - if (_os(s, e.key).isNotEmpty) e.value: _os(s, e.key), - for (final x in s.webdavServers) - if (x.password.isNotEmpty) _davKey(x.id): x.password, - for (final x in s.aiServices) - if (x.apiKey.isNotEmpty) _aiKey(x.id): x.apiKey, - for (final x in s.sftpServers) ...{ - if (x.password.isNotEmpty) _sftpPw(x.id): x.password, - if (x.privateKey.isNotEmpty) _sftpKey(x.id): x.privateKey, - if (x.keyPassphrase.isNotEmpty) _sftpKp(x.id): x.keyPassphrase, - }, - for (final x in s.smbServers) - if (x.password.isNotEmpty) _smbPw(x.id): x.password, - for (final x in s.ftpServers) - if (x.password.isNotEmpty) _ftpPw(x.id): x.password, - // 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (40) - if (s.webdavServers.isNotEmpty) _davServers: jsonEncode([for (final x in s.webdavServers) x.toJson()]), - }; - - /// 저장소에 못 넣은 예전 평문 값을 설정 JSON 에 그대로 둔다 - static void _putPlain(Map j, String key, String value) { - for (final e in _osKeys.entries) { - if (e.value == key) j[e.key] = value; - } - if (key.startsWith('ai.key.')) { - final id = key.substring('ai.key.'.length); - for (final x in (j['aiServices'] as List?) ?? const []) { - if (x is Map && '${x['id']}' == id) x['apiKey'] = value; - } - } - if (key.startsWith('dav.pw.')) { - final id = key.substring('dav.pw.'.length); - for (final x in (j['webdavServers'] as List?) ?? const []) { - if (x is Map && '${x['id']}' == id) x['password'] = value; - } - } - } - - /// 비어 있는 비밀 값을 안전 저장소의 것으로 채운다 (파일에 평문이 있으면 그것을 쓰고 [_filePlain] 에 기억) - void _fill(AppSettings s, Map stored) { - final os = {}; - for (final e in _osKeys.entries) { - final v = _os(s, e.key); - os[e.key] = v.isNotEmpty ? v : stored[e.value] ?? ''; - } - s - ..openSubtitlesKey = os['openSubtitlesKey']! - ..openSubtitlesUser = os['openSubtitlesUser']! - ..openSubtitlesPassword = os['openSubtitlesPassword']!; - s.webdavServers = [ - for (final x in s.webdavServers) - x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''), - ]; - s.aiServices = [ - for (final x in s.aiServices) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''), - ]; - s.sftpServers = [ - for (final x in s.sftpServers) - x.copyWith( - password: x.password.isNotEmpty ? x.password : stored[_sftpPw(x.id)] ?? '', - privateKey: x.privateKey.isNotEmpty ? x.privateKey : stored[_sftpKey(x.id)] ?? '', - keyPassphrase: x.keyPassphrase.isNotEmpty ? x.keyPassphrase : stored[_sftpKp(x.id)] ?? '', - ), - ]; - s.smbServers = [ - for (final x in s.smbServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_smbPw(x.id)] ?? ''), - ]; - s.ftpServers = [ - for (final x in s.ftpServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_ftpPw(x.id)] ?? ''), - ]; - } - - /// 읽은 설정에 안전 저장소의 비밀 값을 채운다. 파일에 예전 평문 값이 있으면 저장소로 옮기고 파일에서 지운다 (54). - Future _loadSecrets(AppSettings s) async { - Map stored; - try { - stored = await secrets.readAll(); - _secretsOk = true; - } catch (_) { - stored = {}; - _secretsOk = false; - } - _known = Map.of(stored); - _filePlain.clear(); - // 예전 판이 서버 목록을 지운 설정 파일 (항목 자체가 없음) → 저장소의 목록으로 - if (!s.extra.containsKey('webdavServers') && stored[_davServers] != null) { - try { - s.webdavServers = [ - for (final x in jsonDecode(stored[_davServers]!) as List) - if (x is Map) DavServer.fromJson(x), - ]; - } catch (_) {} - } - // 파일의 평문 값 (예전 판 · 저장소에 못 넣었던 것) 기억 - for (final e in _osKeys.entries) { - final v = _os(s, e.key); - if (v.isNotEmpty) _filePlain[e.value] = v; - } - for (final x in s.webdavServers) { - if (x.password.isNotEmpty) _filePlain[_davKey(x.id)] = x.password; - } - for (final x in s.aiServices) { - if (x.apiKey.isNotEmpty) _filePlain[_aiKey(x.id)] = x.apiKey; - } - _fill(s, stored); - secretIssue.value = !_secretsOk - ? const SecretIssue(SecretIssueKind.readFailed) - : secrets.lostCopy != null - ? SecretIssue(SecretIssueKind.lost, kept: secrets.lostCopy) - : null; - // 옮기기: 저장소에 쓰고 파일에서 지운다 (못 쓴 것은 파일에 그대로) - if (_filePlain.isNotEmpty && _secretsOk) await save(s); - // 백업으로 되살렸거나 예전 이름의 파일을 옮겨 왔으면, 저장소에 다 들어간 뒤 그 파일들의 평문도 지운다 (40-4) - if (_secretsOk && _filePlain.isEmpty) { - for (final f in _scrub) { - await _scrubFile(f); - } - } - _scrub.clear(); - } - - /// 평문 비밀 값이 남아 있을 수 있는 파일 (.bak 으로 되살림 · jj_capcut 에서 옮김) - final List _scrub = []; - - static Future _scrubFile(File f) async { - try { - if (!await f.exists()) return; - final j = jsonDecode(await f.readAsString()) as Map; - final clean = AppSettings.stripSecrets(j); - if (jsonEncode(clean) == jsonEncode(j)) return; - await f.writeAsString(const JsonEncoder.withIndent(' ').convert(clean), flush: true); - } catch (_) {} - } - - /// 깨진 설정 파일의 보관본에서 비밀 값을 지운다 (JSON 으로 읽을 수 없으니 글자로) - static String redactSecrets(String text) => text.replaceAllMapped( - RegExp(r'"(openSubtitlesKey|openSubtitlesUser|openSubtitlesPassword|password|apiKey|privateKey|keyPassphrase)"\s*:\s*"(?:[^"\\]|\\.)*"'), - (m) => '"${m[1]}": ""'); - - /// 읽기 오류를 알릴 글 (파일 내용은 넣지 않음 - JSON 오류 메시지에는 원문 일부가 들어간다) - static String _describe(Object e) => - e is FormatException ? 'FormatException: ${e.message}${e.offset != null ? ' (offset ${e.offset})' : ''}' : '$e'; - - /// [다시 시도]: 안전 저장소를 다시 읽고 (못 읽었으면) 저장하지 못한 값을 다시 쓴다. 문제가 없어지면 true - Future retrySecrets(AppSettings s) async { - if (!_secretsOk) { - try { - final stored = await secrets.readAll(); - _secretsOk = true; - _known = Map.of(stored); - _fill(s, stored); - } catch (_) { - secretIssue.value = const SecretIssue(SecretIssueKind.readFailed); - return false; - } - } - await save(s); - final i = secretIssue.value; - if (i?.kind == SecretIssueKind.readFailed) secretIssue.value = null; - return secretIssue.value == null || secretIssue.value!.kind == SecretIssueKind.lost; - } - - /// 마지막 [load] 에서 생긴 일 (null = 문제 없음). 앱이 켜질 때 사용자에게 알린다. - SettingsLoadProblem? problem; - - Future _file() async => - _path ?? p.join((await getApplicationSupportDirectory()).path, 'settings.json'); - - static AppSettings _parse(String text) => AppSettings.fromJson(jsonDecode(text) as Map); - - Future load() async { - final s = await _loadFile(); - await _loadSecrets(s); - return s; - } - - Future _loadFile() async { - problem = null; - final f = File(await _file()); - try { - // 이전 이름(jj_capcut) 의 설정 파일이 있으면 옮겨 온다 - final legacy = File(p.join(p.dirname(f.parent.path), 'jj_capcut', 'settings.json')); - if (_path == null && !await f.exists() && await legacy.exists()) { - await f.parent.create(recursive: true); - await legacy.copy(f.path); - _scrub.add(legacy); - } - } catch (_) {} - final bak = File('${f.path}.bak'); - if (!await f.exists()) { - // 바꿔치기 도중 끝나 settings.json 만 없는 경우: 백업이 있으면 그것으로 - if (await bak.exists()) { - try { - final s = _parse(await bak.readAsString()); - problem = SettingsLoadProblem(restoredFromBackup: true, brokenCopy: null, error: tr('settings.json 없음')); - _scrub.add(bak); - return s; - } catch (_) {} - } - return AppSettings(); - } - String? text; - Object? error; - try { - text = await f.readAsString(); - return _parse(text); - } catch (e) { - error = e; - } - // 읽지 못함: 깨진 파일을 보관하고 백업으로 - String? kept; - try { - final stamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.').first; - kept = p.join(f.parent.path, 'settings.broken-$stamp.json'); - // 비밀 값은 지우고 보관 (40-4) - await File(kept).writeAsString(redactSecrets(utf8.decode(await f.readAsBytes(), allowMalformed: true))); - } catch (_) { - kept = null; - } - if (await bak.exists()) { - try { - final s = _parse(await bak.readAsString()); - problem = SettingsLoadProblem(restoredFromBackup: true, brokenCopy: kept, error: _describe(error)); - _scrub.add(bak); - return s; - } catch (_) {} - } - problem = SettingsLoadProblem(restoredFromBackup: false, brokenCopy: kept, error: _describe(error)); - return AppSettings(); - } - - /// 한 번에 하나씩 쓴다 (빠르게 여러 번 바꿔도 파일이 섞이지 않게) - Future _last = Future.value(); - - Future save(AppSettings s) { - final j = s.toJson(); - final values = _secretsOf(s); - return _last = _last.catchError((_) {}).then((_) async { - final unsaved = await _writeSecrets(values); - // 40-1: 저장소에 들어가지 못한 값 중 파일에 원래 평문으로 있던 것은 지우지 않고 그대로 둔다 - for (final k in _filePlain.keys.toList()) { - if (!values.containsKey(k) || !unsaved.contains(k)) { - _filePlain.remove(k); // 사용자가 지웠거나 저장소에 들어감 - } else { - _putPlain(j, k, _filePlain[k]!); - } - } - final missing = unsaved.where((k) => k != _davServers).toList(); - if (missing.isNotEmpty) { - // 91: 새로 넣은 값이 저장되지 않았으면, 앱을 끈 뒤 어떻게 되는지 (예전 값으로 돌아감 / 사라짐) - var reverts = false, lost = false; - for (final k in missing) { - final old = _filePlain[k] ?? _known[k]; - if (old == values[k]) continue; // 예전 평문 그대로 (파일에 남아 있음) - if (old != null) { - reverts = true; - } else { - lost = true; - } - } - secretIssue.value = SecretIssue(_secretsOk ? SecretIssueKind.writeFailed : SecretIssueKind.readFailed, - revertsToOld: reverts, lostOnExit: lost); - } else if (secretIssue.value?.kind == SecretIssueKind.writeFailed) { - secretIssue.value = null; - } - await _write(const JsonEncoder.withIndent(' ').convert(j)); - }); - } - - /// 이 판의 설정이 맡는 안전 저장소 열쇠 - 이 판이 만드는 정확한 이름 꼴만 ([_secretsOf] 와 같은 꼴). - /// 관리자 지시 (10/21, 15 알림): 예전에는 머리글 (dav. · sftp. 등) 만 보아, 새 판이 같은 머리글로 새 종류의 열쇠 (예: dav.token.x) 를 - /// 쓰면 이 판에서 보통 저장할 때 지웠다 - 예전 판으로 되돌리면 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽). - /// 그 밖은 마스터 · 최상 (sec.) 이거나 이 판이 모르는 꼴 (다른 판이 쓴 열쇠) - 보통 저장에서는 건드리지 않는다 - /// 규칙 (관리자 10/21): 새 종류의 비밀 값은 아래 꼴들과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 을 쓴다 (겹치면 예전 판이 저장할 때 지움) - static bool ownsSecret(String k) => secretForms.any((f) => f.hasMatch(k)); - - /// 이 판이 만드는 비밀 열쇠의 꼴 ([_secretsOf] 와 같아야 한다 - 시험이 모든 열쇠가 정확히 한 꼴에만 맞는지 본다) - static final secretForms = [ - for (final k in _osKeys.values) RegExp('^${RegExp.escape(k)}\$'), - RegExp('^${RegExp.escape(_davServers)}\$'), - for (final pre in const ['dav.pw', 'sftp.pw', 'sftp.key', 'sftp.kp', 'smb.pw', 'ftp.pw', 'ai.key']) RegExp('^${RegExp.escape(pre)}' r'\..+$'), - ]; - - /// 관리자 판단 (10/21): 이 판이 모르는 꼴의 비밀 값 (다른 판이 쓴 열쇠 - 머리글이 같아도) 을 지운다 - 사용자가 초기화 창에서 - /// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다. - /// 지운 열쇠 수 - Future clearForeignSecrets() async { - final all = await secrets.readAll(); - var n = 0; - for (final k in all.keys) { - if (k.startsWith('sec.') || ownsSecret(k)) continue; - await secrets.delete(k); - _known.remove(k); - n++; - } - return n; - } - - /// 바뀐 비밀 값만 안전 저장소에 쓴다 (빈 값 · 지운 서버는 저장소에서도 지움). 저장소에 넣지 못한 열쇠를 돌려준다. - Future> _writeSecrets(Map values) async { - if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])}; - final unsaved = {}; - for (final k in {..._known.keys, ...values.keys}) { - if (!ownsSecret(k)) continue; - final v = values[k]; - if (v == _known[k]) continue; - try { - if (v == null) { - await secrets.delete(k); - _known.remove(k); - } else { - await secrets.write(k, v); - _known[k] = v; - } - } catch (_) { - if (v != null) unsaved.add(k); - } - } - return unsaved; - } - - Future _write(String text) async { - final f = File(await _file()); - await f.parent.create(recursive: true); - final tmp = File('${f.path}.tmp'); - await tmp.writeAsString(text, flush: true); - if (await f.exists()) { - // 지금 파일이 정상이면 백업으로 남긴다 (깨진 파일로 좋은 백업을 덮지 않게). 비밀 값은 빼고 (54) - try { - final j = jsonDecode(await f.readAsString()) as Map; - AppSettings.fromJson(j); - // 저장소에 못 넣어 파일에 남겨 둔 평문이 있으면 백업에도 그대로 (40-1) - await File('${f.path}.bak').writeAsString( - const JsonEncoder.withIndent(' ').convert(_filePlain.isNotEmpty ? j : AppSettings.stripSecrets(j)), - flush: true); - } catch (_) {} - } - await tmp.rename(f.path); - } -} - -/// 안전 저장소 문제의 종류 -enum SecretIssueKind { - /// 안전 저장소를 읽을 수 없음 (이번 실행에서 넣은 비밀번호는 저장되지 않음) - readFailed, - - /// 안전 저장소에 쓰지 못함 (넣은 비밀번호가 저장되지 않음) - writeFailed, - - /// 안전 저장소를 풀 수 없어 저장된 비밀번호를 되살리지 못함 (풀지 못한 파일은 [SecretIssue.kept] 에 보관) - lost, -} - -class SecretIssue { - final SecretIssueKind kind; - final String? kept; - - /// 91: 저장하지 못한 새 값 중 예전 값이 있는 것이 있다 (앱을 끄면 예전 비밀번호로 돌아감) - final bool revertsToOld; - - /// 저장하지 못한 새 값 중 예전 값이 없는 것이 있다 (앱을 끄면 사라짐) - final bool lostOnExit; - - const SecretIssue(this.kind, {this.kept, this.revertsToOld = false, this.lostOnExit = false}); - - // 같은 상황이면 알림을 다시 띄우지 않게 (90) - @override - bool operator ==(Object other) => - other is SecretIssue && - other.kind == kind && - other.kept == kept && - other.revertsToOld == revertsToOld && - other.lostOnExit == lostOnExit; - @override - int get hashCode => Object.hash(kind, kept, revertsToOld, lostOnExit); -} - -/// 설정 파일을 읽다 생긴 일 (앱이 켜질 때 알림) -class SettingsLoadProblem { - /// 백업 (직전 정상 저장) 으로 되살렸는지. false 면 처음 설정으로 켰다. - final bool restoredFromBackup; - - /// 읽지 못한 원래 파일을 보관한 곳 - final String? brokenCopy; - final String error; - const SettingsLoadProblem({required this.restoredFromBackup, required this.brokenCopy, required this.error}); -} diff --git a/lib/app/settings/records.dart b/lib/app/settings/records.dart new file mode 100644 index 0000000..97b43dd --- /dev/null +++ b/lib/app/settings/records.dart @@ -0,0 +1,236 @@ + + + +// 약속 (설정 A): 설정 안의 기록 - 이동 버튼 · 즐겨찾기 · 실시간 동기화 쌍 · 복사 작업. 저마다 toJson · fromJson + +/// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더 +class MoveTarget { + final String name; + final String dir; + const MoveTarget(this.name, this.dir); + + MoveTarget copyWith({String? name, String? dir}) => MoveTarget(name ?? this.name, dir ?? this.dir); + + Map toJson() => {'name': name, 'dir': dir}; + + factory MoveTarget.fromJson(Map j) => + MoveTarget((j['name'] as String?)?.trim().isNotEmpty == true ? j['name'] as String : '이동', j['dir'] as String); + + @override + bool operator ==(Object other) => other is MoveTarget && other.name == name && other.dir == dir; + + @override + int get hashCode => Object.hash(name, dir); +} + +/// 73: 파일 탐색기 즐겨찾기 (폴더). [right] 가 있으면 Rsync 화면의 "왼쪽 + 오른쪽" 짝 +class FolderBookmark { + final String name; + final String path; + final String? right; + const FolderBookmark({required this.name, required this.path, this.right}); + + bool get isPair => right != null; + + FolderBookmark copyWith({String? name}) => FolderBookmark(name: name ?? this.name, path: path, right: right); + + Map toJson() => {'name': name, 'path': path, if (right != null) 'right': right}; + + static FolderBookmark? fromJson(Object? j) => j is Map && j['path'] is String + ? FolderBookmark( + name: j['name'] is String && (j['name'] as String).trim().isNotEmpty ? j['name'] as String : j['path'] as String, + path: j['path'] as String, + right: j['right'] is String ? j['right'] as String : null) + : null; +} + +/// 실시간 동기화 한 쌍 (lsyncd 처럼: 원본 폴더를 지켜보다가 바뀌면 대상 폴더에 맞춘다) +class LiveSyncPair { + final String source; + final String target; + + /// 'builtin' · 'rsync' · 'robocopy' (CopyMethod 이름) + final String method; + + /// 원본에 없는 것을 대상에서 지우기 (rsync --delete, robocopy /PURGE) + final bool delete; + final bool enabled; + + /// 동작 시간 (cron 줄들, core/cron_window.dart). 비어 있으면 계속 (앱이 켜져 있는 동안 늘) + final List schedule; + + /// "지우기" 를 사용자가 지울 목록을 보고 확인했는지. 확인 전에는 지울 것이 있으면 지우지 않고 맞추며 확인을 기다린다 (42). + /// 이 값이 생기기 전부터 돌던 쌍은 확인한 것으로 본다. + final bool deleteConfirmed; + + /// 지우기를 확인할 때의 rsync 옵션 (rsync 쌍만, 그 밖은 ''). 확인한 뒤 옵션이 바뀌어 지울 것이 늘면 다시 묻는다 + /// (관리자 3a 판단 10/20 - --delete-excluded · --exclude · --filter 등). null = 이 값이 생기기 전에 확인한 쌍 + final String? confirmedOptions; + const LiveSyncPair(this.source, this.target, + {this.method = 'builtin', + this.delete = false, + this.enabled = true, + this.schedule = const [], + this.deleteConfirmed = false, + this.confirmedOptions}); + + /// [delete] 를 새로 켜면 확인은 다시 받는다 + LiveSyncPair copyWith( + {String? method, bool? delete, bool? enabled, List? schedule, bool? deleteConfirmed, String? confirmedOptions}) => + LiveSyncPair(source, target, + method: method ?? this.method, + delete: delete ?? this.delete, + enabled: enabled ?? this.enabled, + schedule: schedule ?? this.schedule, + deleteConfirmed: deleteConfirmed ?? (delete == true && !this.delete ? false : this.deleteConfirmed), + confirmedOptions: confirmedOptions ?? this.confirmedOptions); + + Map toJson() => { + 'source': source, + 'target': target, + 'method': method, + 'delete': delete, + 'enabled': enabled, + 'schedule': schedule, + 'deleteConfirmed': deleteConfirmed, + if (confirmedOptions != null) 'confirmedOptions': confirmedOptions, + }; + + factory LiveSyncPair.fromJson(Map j) => LiveSyncPair( + j['source'] as String, + j['target'] as String, + method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin', + delete: j['delete'] == true, + enabled: j['enabled'] != false, + schedule: [for (final x in (j['schedule'] as List?) ?? const []) '$x'], + // 이 값이 없는 예전 쌍은 이미 지우기 포함으로 돌던 것 → 확인한 것으로 + deleteConfirmed: j['deleteConfirmed'] as bool? ?? true, + confirmedOptions: j['confirmedOptions'] is String ? j['confirmedOptions'] as String : null, + ); +} + +/// 복사 · 이동 기억 (모니터링 > 복사 · rsync): 같은 원본 → 대상을 다시 복사하면 이 옵션을 쓴다. 언제든 [실행] 으로 다시. +class CopyTask { + final String id; + final List sources; + final String dest; + final bool move; + + /// 폴더 "안의 것" 을 대상에 맞추기 (lsync 에서 옮겨 온 것: 원본/ → 대상/) + final bool contents; + + /// CopyMethod 이름 · 그 방법의 옵션 · 한 번에 · 속도 제한 (KB/s) + final String method; + final String options; + final bool once; + final int bandwidthKBps; + + /// 마지막 실행: 시각 (ISO) · 결과 ('done' · 'failed' · 'cancelled' · '') · 글 · 파일 수 + final String lastRun; + final String lastResult; + final String lastMessage; + final int lastFiles; + + /// 235: 이동 뒤 원본에 남은 파일 수 (건너뜀 - 216). 카드에 "n개는 원본에 남음" 으로 + final int lastLeft; + + /// 237: 실패 원문 (예외 이름 · 명령 줄 포함) - 카드의 [자세히] 에서 보고 복사한다. 화면 글은 [lastMessage] + final String lastDetail; + + /// 이동 ([move], rsync --remove-source-files) 뒤 원본 정리: '' 그대로 · 'keep' 빈 폴더를 지우고 원본 폴더는 남김 · + /// 'all' 원본 폴더까지 (비었으면) 지움 (find 원본/ -type d -empty -delete) + final String prune; + + const CopyTask({ + required this.id, + required this.sources, + required this.dest, + this.move = false, + this.contents = false, + this.method = 'builtin', + this.options = '', + this.once = false, + this.bandwidthKBps = 0, + this.lastRun = '', + this.lastResult = '', + this.lastMessage = '', + this.lastFiles = 0, + this.lastLeft = 0, + this.lastDetail = '', + this.prune = '', + }); + + /// 같은 복사인지 (원본들 · 대상 · 이동) + String get key => '${move ? 'M' : contents ? 'S' : 'C'}|${[...sources]..sort()}|$dest'; + + CopyTask copyWith({ + String? method, + String? options, + bool? once, + int? bandwidthKBps, + bool? move, + String? lastRun, + String? lastResult, + String? lastMessage, + int? lastFiles, + int? lastLeft, + String? lastDetail, + String? prune, + }) => + CopyTask( + id: id, + sources: sources, + dest: dest, + move: move ?? this.move, + contents: contents, + method: method ?? this.method, + options: options ?? this.options, + once: once ?? this.once, + bandwidthKBps: bandwidthKBps ?? this.bandwidthKBps, + lastRun: lastRun ?? this.lastRun, + lastResult: lastResult ?? this.lastResult, + lastMessage: lastMessage ?? this.lastMessage, + lastFiles: lastFiles ?? this.lastFiles, + lastLeft: lastLeft ?? this.lastLeft, + lastDetail: lastDetail ?? this.lastDetail, + prune: prune ?? this.prune, + ); + + Map toJson() => { + 'id': id, + 'sources': sources, + 'dest': dest, + 'move': move, + 'contents': contents, + 'method': method, + 'options': options, + 'once': once, + 'bandwidthKBps': bandwidthKBps, + 'lastRun': lastRun, + 'lastResult': lastResult, + 'lastMessage': lastMessage, + 'lastFiles': lastFiles, + 'lastLeft': lastLeft, + 'lastDetail': lastDetail, + 'prune': prune, + }; + + factory CopyTask.fromJson(Map j) => CopyTask( + id: '${j['id']}', + sources: [for (final x in (j['sources'] as List?) ?? const []) '$x'], + dest: '${j['dest']}', + move: j['move'] == true, + contents: j['contents'] == true, + method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin', + options: j['options'] as String? ?? '', + once: j['once'] == true, + bandwidthKBps: (j['bandwidthKBps'] as num?)?.toInt() ?? 0, + lastRun: j['lastRun'] as String? ?? '', + lastResult: j['lastResult'] as String? ?? '', + lastMessage: j['lastMessage'] as String? ?? '', + lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0, + lastLeft: (j['lastLeft'] as num?)?.toInt() ?? 0, + lastDetail: j['lastDetail'] as String? ?? '', + prune: const ['keep', 'all'].contains(j['prune']) ? j['prune'] as String : '', + ); +} diff --git a/lib/app/settings/settings_group.dart b/lib/app/settings/settings_group.dart new file mode 100644 index 0000000..0be6743 --- /dev/null +++ b/lib/app/settings/settings_group.dart @@ -0,0 +1,10 @@ +/// 약속 (설정 A): 설정 분야 하나 - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다. +/// 설정 하나를 더할 때는 그 분야 파일만 고친다 (settings.dart 의 toJson · fromJson 은 고치지 않음 - 분야 목록을 돌며 합치기만). +/// 저장 파일 모양 (평평한 키) 은 그대로 - 키 이름을 바꾸지 않는다 +abstract class SettingsGroup { + /// 저장할 키와 값을 [j] 에 넣는다. [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 비밀 값도 파일에 + void write(Map j, {required bool plainSecrets}); + + /// 설정 파일 [j] 에서 읽는다 - 없거나 틀린 값은 처음 값 (또는 고친 값) + void read(Map j); +} diff --git a/lib/app/settings/settings_store.dart b/lib/app/settings/settings_store.dart new file mode 100644 index 0000000..8d9301a --- /dev/null +++ b/lib/app/settings/settings_store.dart @@ -0,0 +1,454 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../core/webdav.dart'; +import '../../services/secret_store.dart'; +import '../../l10n/tr.dart'; +import '../settings.dart'; + +/// 설정 파일 읽기·쓰기 +/// 설정 파일 읽기 · 쓰기. +/// - 쓰기: 임시 파일에 다 쓴 뒤 바꿔치기 (쓰는 도중 앱이 끝나도 settings.json 이 깨지지 않게). +/// 바꿔치기 전의 정상 파일은 settings.json.bak 으로 남긴다. +/// - 읽기: settings.json 을 읽지 못하면 settings.json.bak 으로 되살린다. 읽지 못한 파일은 지우지 않고 +/// settings.broken-(시각).json 으로 보관한다 (다음 저장에 덮여 사라지지 않게). 무슨 일이 있었는지는 [problem]. +class SettingsStore { + final String? _path; + + /// 비밀 값 (WebDAV 비밀번호 · OpenSubtitles) 을 두는 곳. 경로를 직접 주면 (시험) 메모리 + final SecretStore secrets; + + SettingsStore([this._path, SecretStore? secrets]) + : secrets = secrets ?? (_path == null ? SecretStore.platform() : MemorySecretStore()); + + /// 안전 저장소를 읽을 수 있었는지 (못 읽었으면 저장소를 건드리지 않는다 - 지워 버리지 않게) + bool _secretsOk = true; + + /// 안전 저장소에 마지막으로 쓴 (읽은) 값 - 바뀐 것만 쓴다 + Map _known = {}; + + /// 설정 파일에 예전부터 평문으로 있던 값 (저장소 열쇠 → 값). 안전 저장소에 들어가기 전까지는 파일에서 지우지 않는다 (40-1). + /// 새로 넣은 값은 저장소에 못 쓰더라도 평문으로 쓰지 않는다 (사용자 결정) - 대신 [secretIssue] 로 알린다. + final Map _filePlain = {}; + + /// 안전 저장소 문제 (화면 위 알림 · [다시 시도]). null = 문제 없음 + final secretIssue = ValueNotifier(null); + + /// 안전 저장소의 열쇠 + static const _osKeys = { + 'openSubtitlesKey': 'os.key', + 'openSubtitlesUser': 'os.user', + 'openSubtitlesPassword': 'os.password', + }; + static String _davKey(String id) => 'dav.pw.$id'; + static String _sftpPw(String id) => 'sftp.pw.$id'; + static String _sftpKey(String id) => 'sftp.key.$id'; + static String _sftpKp(String id) => 'sftp.kp.$id'; + static String _smbPw(String id) => 'smb.pw.$id'; + static String _ftpPw(String id) => 'ftp.pw.$id'; + static String _aiKey(String id) => 'ai.key.$id'; + static const _davServers = 'dav.servers'; + + static String _os(AppSettings s, String k) => switch (k) { + 'openSubtitlesKey' => s.openSubtitlesKey, + 'openSubtitlesUser' => s.openSubtitlesUser, + _ => s.openSubtitlesPassword, + }; + + /// 지금 설정의 비밀 값 (빈 값은 넣지 않음 = 저장소에서 지움) + /// 규칙 (관리자 10/21): 새 종류의 비밀 값은 지금 꼴 ([secretForms]) 과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 로 만든다 - + /// 겹치면 그 꼴을 아는 예전 판이 저장할 때 "없어진 항목" 으로 보고 지운다. 새 꼴은 [secretForms] 에도 더한다 + static Map _secretsOf(AppSettings s) => !s.rememberPasswords ? const {} : { + for (final e in _osKeys.entries) + if (_os(s, e.key).isNotEmpty) e.value: _os(s, e.key), + for (final x in s.webdavServers) + if (x.password.isNotEmpty) _davKey(x.id): x.password, + for (final x in s.aiServices) + if (x.apiKey.isNotEmpty) _aiKey(x.id): x.apiKey, + for (final x in s.sftpServers) ...{ + if (x.password.isNotEmpty) _sftpPw(x.id): x.password, + if (x.privateKey.isNotEmpty) _sftpKey(x.id): x.privateKey, + if (x.keyPassphrase.isNotEmpty) _sftpKp(x.id): x.keyPassphrase, + }, + for (final x in s.smbServers) + if (x.password.isNotEmpty) _smbPw(x.id): x.password, + for (final x in s.ftpServers) + if (x.password.isNotEmpty) _ftpPw(x.id): x.password, + // 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (40) + if (s.webdavServers.isNotEmpty) _davServers: jsonEncode([for (final x in s.webdavServers) x.toJson()]), + }; + + /// 저장소에 못 넣은 예전 평문 값을 설정 JSON 에 그대로 둔다 + static void _putPlain(Map j, String key, String value) { + for (final e in _osKeys.entries) { + if (e.value == key) j[e.key] = value; + } + if (key.startsWith('ai.key.')) { + final id = key.substring('ai.key.'.length); + for (final x in (j['aiServices'] as List?) ?? const []) { + if (x is Map && '${x['id']}' == id) x['apiKey'] = value; + } + } + if (key.startsWith('dav.pw.')) { + final id = key.substring('dav.pw.'.length); + for (final x in (j['webdavServers'] as List?) ?? const []) { + if (x is Map && '${x['id']}' == id) x['password'] = value; + } + } + } + + /// 비어 있는 비밀 값을 안전 저장소의 것으로 채운다 (파일에 평문이 있으면 그것을 쓰고 [_filePlain] 에 기억) + void _fill(AppSettings s, Map stored) { + final os = {}; + for (final e in _osKeys.entries) { + final v = _os(s, e.key); + os[e.key] = v.isNotEmpty ? v : stored[e.value] ?? ''; + } + s + ..openSubtitlesKey = os['openSubtitlesKey']! + ..openSubtitlesUser = os['openSubtitlesUser']! + ..openSubtitlesPassword = os['openSubtitlesPassword']!; + s.webdavServers = [ + for (final x in s.webdavServers) + x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''), + ]; + s.aiServices = [ + for (final x in s.aiServices) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''), + ]; + s.sftpServers = [ + for (final x in s.sftpServers) + x.copyWith( + password: x.password.isNotEmpty ? x.password : stored[_sftpPw(x.id)] ?? '', + privateKey: x.privateKey.isNotEmpty ? x.privateKey : stored[_sftpKey(x.id)] ?? '', + keyPassphrase: x.keyPassphrase.isNotEmpty ? x.keyPassphrase : stored[_sftpKp(x.id)] ?? '', + ), + ]; + s.smbServers = [ + for (final x in s.smbServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_smbPw(x.id)] ?? ''), + ]; + s.ftpServers = [ + for (final x in s.ftpServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_ftpPw(x.id)] ?? ''), + ]; + } + + /// 읽은 설정에 안전 저장소의 비밀 값을 채운다. 파일에 예전 평문 값이 있으면 저장소로 옮기고 파일에서 지운다 (54). + Future _loadSecrets(AppSettings s) async { + Map stored; + try { + stored = await secrets.readAll(); + _secretsOk = true; + } catch (_) { + // TODO(M0): 비밀 저장소를 못 읽으면 빈 것으로 보고 이어 간다 - 마스터가 없는 것으로 보여 문이 열린다 (보안). + // 설정 나누기 (A) 뒤 따로 고친다 (관리자 10/22). 나누기 동안 동작은 그대로 + stored = {}; + _secretsOk = false; + } + _known = Map.of(stored); + _filePlain.clear(); + // 예전 판이 서버 목록을 지운 설정 파일 (항목 자체가 없음) → 저장소의 목록으로 + if (!s.extra.containsKey('webdavServers') && stored[_davServers] != null) { + try { + s.webdavServers = [ + for (final x in jsonDecode(stored[_davServers]!) as List) + if (x is Map) DavServer.fromJson(x), + ]; + } catch (_) {} + } + // 파일의 평문 값 (예전 판 · 저장소에 못 넣었던 것) 기억 + for (final e in _osKeys.entries) { + final v = _os(s, e.key); + if (v.isNotEmpty) _filePlain[e.value] = v; + } + for (final x in s.webdavServers) { + if (x.password.isNotEmpty) _filePlain[_davKey(x.id)] = x.password; + } + for (final x in s.aiServices) { + if (x.apiKey.isNotEmpty) _filePlain[_aiKey(x.id)] = x.apiKey; + } + _fill(s, stored); + secretIssue.value = !_secretsOk + ? const SecretIssue(SecretIssueKind.readFailed) + : secrets.lostCopy != null + ? SecretIssue(SecretIssueKind.lost, kept: secrets.lostCopy) + : null; + // 옮기기: 저장소에 쓰고 파일에서 지운다 (못 쓴 것은 파일에 그대로) + if (_filePlain.isNotEmpty && _secretsOk) await save(s); + // 백업으로 되살렸거나 예전 이름의 파일을 옮겨 왔으면, 저장소에 다 들어간 뒤 그 파일들의 평문도 지운다 (40-4) + if (_secretsOk && _filePlain.isEmpty) { + for (final f in _scrub) { + await _scrubFile(f); + } + } + _scrub.clear(); + } + + /// 평문 비밀 값이 남아 있을 수 있는 파일 (.bak 으로 되살림 · jj_capcut 에서 옮김) + final List _scrub = []; + + static Future _scrubFile(File f) async { + try { + if (!await f.exists()) return; + final j = jsonDecode(await f.readAsString()) as Map; + final clean = AppSettings.stripSecrets(j); + if (jsonEncode(clean) == jsonEncode(j)) return; + await f.writeAsString(const JsonEncoder.withIndent(' ').convert(clean), flush: true); + } catch (_) {} + } + + /// 깨진 설정 파일의 보관본에서 비밀 값을 지운다 (JSON 으로 읽을 수 없으니 글자로) + static String redactSecrets(String text) => text.replaceAllMapped( + RegExp(r'"(openSubtitlesKey|openSubtitlesUser|openSubtitlesPassword|password|apiKey|privateKey|keyPassphrase)"\s*:\s*"(?:[^"\\]|\\.)*"'), + (m) => '"${m[1]}": ""'); + + /// 읽기 오류를 알릴 글 (파일 내용은 넣지 않음 - JSON 오류 메시지에는 원문 일부가 들어간다) + static String _describe(Object e) => + e is FormatException ? 'FormatException: ${e.message}${e.offset != null ? ' (offset ${e.offset})' : ''}' : '$e'; + + /// [다시 시도]: 안전 저장소를 다시 읽고 (못 읽었으면) 저장하지 못한 값을 다시 쓴다. 문제가 없어지면 true + Future retrySecrets(AppSettings s) async { + if (!_secretsOk) { + try { + final stored = await secrets.readAll(); + _secretsOk = true; + _known = Map.of(stored); + _fill(s, stored); + } catch (_) { + secretIssue.value = const SecretIssue(SecretIssueKind.readFailed); + return false; + } + } + await save(s); + final i = secretIssue.value; + if (i?.kind == SecretIssueKind.readFailed) secretIssue.value = null; + return secretIssue.value == null || secretIssue.value!.kind == SecretIssueKind.lost; + } + + /// 마지막 [load] 에서 생긴 일 (null = 문제 없음). 앱이 켜질 때 사용자에게 알린다. + SettingsLoadProblem? problem; + + Future _file() async => + _path ?? p.join((await getApplicationSupportDirectory()).path, 'settings.json'); + + static AppSettings _parse(String text) => AppSettings.fromJson(jsonDecode(text) as Map); + + Future load() async { + final s = await _loadFile(); + await _loadSecrets(s); + return s; + } + + Future _loadFile() async { + problem = null; + final f = File(await _file()); + try { + // 이전 이름(jj_capcut) 의 설정 파일이 있으면 옮겨 온다 + final legacy = File(p.join(p.dirname(f.parent.path), 'jj_capcut', 'settings.json')); + if (_path == null && !await f.exists() && await legacy.exists()) { + await f.parent.create(recursive: true); + await legacy.copy(f.path); + _scrub.add(legacy); + } + } catch (_) {} + final bak = File('${f.path}.bak'); + if (!await f.exists()) { + // 바꿔치기 도중 끝나 settings.json 만 없는 경우: 백업이 있으면 그것으로 + if (await bak.exists()) { + try { + final s = _parse(await bak.readAsString()); + problem = SettingsLoadProblem(restoredFromBackup: true, brokenCopy: null, error: tr('settings.json 없음')); + _scrub.add(bak); + return s; + } catch (_) {} + } + return AppSettings(); + } + String? text; + Object? error; + try { + text = await f.readAsString(); + return _parse(text); + } catch (e) { + error = e; + } + // 읽지 못함: 깨진 파일을 보관하고 백업으로 + String? kept; + try { + final stamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.').first; + kept = p.join(f.parent.path, 'settings.broken-$stamp.json'); + // 비밀 값은 지우고 보관 (40-4) + await File(kept).writeAsString(redactSecrets(utf8.decode(await f.readAsBytes(), allowMalformed: true))); + } catch (_) { + kept = null; + } + if (await bak.exists()) { + try { + final s = _parse(await bak.readAsString()); + problem = SettingsLoadProblem(restoredFromBackup: true, brokenCopy: kept, error: _describe(error)); + _scrub.add(bak); + return s; + } catch (_) {} + } + problem = SettingsLoadProblem(restoredFromBackup: false, brokenCopy: kept, error: _describe(error)); + return AppSettings(); + } + + /// 한 번에 하나씩 쓴다 (빠르게 여러 번 바꿔도 파일이 섞이지 않게) + Future _last = Future.value(); + + Future save(AppSettings s) { + final j = s.toJson(); + final values = _secretsOf(s); + return _last = _last.catchError((_) {}).then((_) async { + final unsaved = await _writeSecrets(values); + // 40-1: 저장소에 들어가지 못한 값 중 파일에 원래 평문으로 있던 것은 지우지 않고 그대로 둔다 + for (final k in _filePlain.keys.toList()) { + if (!values.containsKey(k) || !unsaved.contains(k)) { + _filePlain.remove(k); // 사용자가 지웠거나 저장소에 들어감 + } else { + _putPlain(j, k, _filePlain[k]!); + } + } + final missing = unsaved.where((k) => k != _davServers).toList(); + if (missing.isNotEmpty) { + // 91: 새로 넣은 값이 저장되지 않았으면, 앱을 끈 뒤 어떻게 되는지 (예전 값으로 돌아감 / 사라짐) + var reverts = false, lost = false; + for (final k in missing) { + final old = _filePlain[k] ?? _known[k]; + if (old == values[k]) continue; // 예전 평문 그대로 (파일에 남아 있음) + if (old != null) { + reverts = true; + } else { + lost = true; + } + } + secretIssue.value = SecretIssue(_secretsOk ? SecretIssueKind.writeFailed : SecretIssueKind.readFailed, + revertsToOld: reverts, lostOnExit: lost); + } else if (secretIssue.value?.kind == SecretIssueKind.writeFailed) { + secretIssue.value = null; + } + await _write(const JsonEncoder.withIndent(' ').convert(j)); + }); + } + + /// 이 판의 설정이 맡는 안전 저장소 열쇠 - 이 판이 만드는 정확한 이름 꼴만 ([_secretsOf] 와 같은 꼴). + /// 관리자 지시 (10/21, 15 알림): 예전에는 머리글 (dav. · sftp. 등) 만 보아, 새 판이 같은 머리글로 새 종류의 열쇠 (예: dav.token.x) 를 + /// 쓰면 이 판에서 보통 저장할 때 지웠다 - 예전 판으로 되돌리면 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽). + /// 그 밖은 마스터 · 최상 (sec.) 이거나 이 판이 모르는 꼴 (다른 판이 쓴 열쇠) - 보통 저장에서는 건드리지 않는다 + /// 규칙 (관리자 10/21): 새 종류의 비밀 값은 아래 꼴들과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 을 쓴다 (겹치면 예전 판이 저장할 때 지움) + static bool ownsSecret(String k) => secretForms.any((f) => f.hasMatch(k)); + + /// 이 판이 만드는 비밀 열쇠의 꼴 ([_secretsOf] 와 같아야 한다 - 시험이 모든 열쇠가 정확히 한 꼴에만 맞는지 본다) + static final secretForms = [ + for (final k in _osKeys.values) RegExp('^${RegExp.escape(k)}\$'), + RegExp('^${RegExp.escape(_davServers)}\$'), + for (final pre in const ['dav.pw', 'sftp.pw', 'sftp.key', 'sftp.kp', 'smb.pw', 'ftp.pw', 'ai.key']) RegExp('^${RegExp.escape(pre)}' r'\..+$'), + ]; + + /// 관리자 판단 (10/21): 이 판이 모르는 꼴의 비밀 값 (다른 판이 쓴 열쇠 - 머리글이 같아도) 을 지운다 - 사용자가 초기화 창에서 + /// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다. + /// 지운 열쇠 수 + Future clearForeignSecrets() async { + final all = await secrets.readAll(); + var n = 0; + for (final k in all.keys) { + if (k.startsWith('sec.') || ownsSecret(k)) continue; + await secrets.delete(k); + _known.remove(k); + n++; + } + return n; + } + + /// 바뀐 비밀 값만 안전 저장소에 쓴다 (빈 값 · 지운 서버는 저장소에서도 지움). 저장소에 넣지 못한 열쇠를 돌려준다. + Future> _writeSecrets(Map values) async { + if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])}; + final unsaved = {}; + for (final k in {..._known.keys, ...values.keys}) { + if (!ownsSecret(k)) continue; + final v = values[k]; + if (v == _known[k]) continue; + try { + if (v == null) { + await secrets.delete(k); + _known.remove(k); + } else { + await secrets.write(k, v); + _known[k] = v; + } + } catch (_) { + if (v != null) unsaved.add(k); + } + } + return unsaved; + } + + Future _write(String text) async { + final f = File(await _file()); + await f.parent.create(recursive: true); + final tmp = File('${f.path}.tmp'); + await tmp.writeAsString(text, flush: true); + if (await f.exists()) { + // 지금 파일이 정상이면 백업으로 남긴다 (깨진 파일로 좋은 백업을 덮지 않게). 비밀 값은 빼고 (54) + try { + final j = jsonDecode(await f.readAsString()) as Map; + AppSettings.fromJson(j); + // 저장소에 못 넣어 파일에 남겨 둔 평문이 있으면 백업에도 그대로 (40-1) + await File('${f.path}.bak').writeAsString( + const JsonEncoder.withIndent(' ').convert(_filePlain.isNotEmpty ? j : AppSettings.stripSecrets(j)), + flush: true); + } catch (_) {} + } + await tmp.rename(f.path); + } +} + +/// 안전 저장소 문제의 종류 +enum SecretIssueKind { + /// 안전 저장소를 읽을 수 없음 (이번 실행에서 넣은 비밀번호는 저장되지 않음) + readFailed, + + /// 안전 저장소에 쓰지 못함 (넣은 비밀번호가 저장되지 않음) + writeFailed, + + /// 안전 저장소를 풀 수 없어 저장된 비밀번호를 되살리지 못함 (풀지 못한 파일은 [SecretIssue.kept] 에 보관) + lost, +} + +class SecretIssue { + final SecretIssueKind kind; + final String? kept; + + /// 91: 저장하지 못한 새 값 중 예전 값이 있는 것이 있다 (앱을 끄면 예전 비밀번호로 돌아감) + final bool revertsToOld; + + /// 저장하지 못한 새 값 중 예전 값이 없는 것이 있다 (앱을 끄면 사라짐) + final bool lostOnExit; + + const SecretIssue(this.kind, {this.kept, this.revertsToOld = false, this.lostOnExit = false}); + + // 같은 상황이면 알림을 다시 띄우지 않게 (90) + @override + bool operator ==(Object other) => + other is SecretIssue && + other.kind == kind && + other.kept == kept && + other.revertsToOld == revertsToOld && + other.lostOnExit == lostOnExit; + @override + int get hashCode => Object.hash(kind, kept, revertsToOld, lostOnExit); +} + +/// 설정 파일을 읽다 생긴 일 (앱이 켜질 때 알림) +class SettingsLoadProblem { + /// 백업 (직전 정상 저장) 으로 되살렸는지. false 면 처음 설정으로 켰다. + final bool restoredFromBackup; + + /// 읽지 못한 원래 파일을 보관한 곳 + final String? brokenCopy; + final String error; + const SettingsLoadProblem({required this.restoredFromBackup, required this.brokenCopy, required this.error}); +} diff --git a/test/fixtures/settings_golden_default.json b/test/fixtures/settings_golden_default.json new file mode 100644 index 0000000..d58b9a3 --- /dev/null +++ b/test/fixtures/settings_golden_default.json @@ -0,0 +1,243 @@ +{ + "addFinishedDownloads": true, + "aiAgreed": [], + "aiAutoDevice": "", + "aiAutoDeviceFor": "", + "aiCfg": 1.0, + "aiCount": 1, + "aiDevice": "auto", + "aiEngine": "local", + "aiHeight": 512, + "aiImageV1": true, + "aiNegative": "", + "aiPrompt": "", + "aiQualityButton": false, + "aiQualityStrength": "normal", + "aiRecent": [], + "aiSaveDir": "", + "aiServices": [], + "aiSource": "und", + "aiSteps": 4, + "aiStrength": 0.6, + "aiTaesd": true, + "aiTargets": [ + "ko", + "en", + "ja" + ], + "aiUpAfter": "upscaled", + "aiUpDir": "", + "aiUpFormat": "same", + "aiUpJpgQuality": 92, + "aiUpMemory": "normal", + "aiUpModel": "auto", + "aiUpScale": 2, + "aiUpSecPerMp": {}, + "aiWhisper": "whisper-base", + "aiWidth": 512, + "allFilesHint2": false, + "allowInnerToOuter": true, + "androidTrashDays": 14, + "appIcon": "yellow", + "askAiOptions": true, + "autoCheckUpdates": true, + "backgroundV2": true, + "batchRenameLast": {}, + "browserEngine": "edge", + "browserSwipeHinted": false, + "cefWhenElevated": false, + "cleanupPrechecked": [ + "work", + "download" + ], + "clipboardWatch": true, + "closeAction": "background", + "components": [ + "mkv", + "browser", + "explorer", + "rsync", + "downloads", + "aiimage", + "viewer" + ], + "cookieScopeV2": true, + "copyBandwidthKBps": 0, + "copyConflict": "ask", + "copyMethodFile": "builtin", + "copyMethodFolder": "builtin", + "copyRunMode": "each", + "copySkipNewer": true, + "copyTasks": [], + "davExternalOpen": "ask", + "dnsDohUrl": "https://dns.google/dns-query", + "dnsMode": "custom", + "dnsPrimary": "8.8.8.8", + "dnsSecondary": "8.8.4.4", + "downloadRoot": null, + "encode": { + "brightness": 0, + "codec": "copy", + "contrast": 0, + "fit": "fill", + "frame": "original", + "quality": "normal", + "resolution": "original", + "rotate": "none", + "saturation": 0, + "temperature": 0 + }, + "explorerAlwaysMarks": false, + "explorerButtons": [], + "explorerClick": "select", + "explorerDropAction": "ask", + "explorerFolderSize": "sort", + "explorerFolderSorts": {}, + "explorerHiddenItems": [], + "explorerHideConfirm": true, + "explorerHistory": [], + "explorerKeyOverrides": {}, + "explorerLayout": "dual", + "explorerOnePaneCopy": "clip", + "explorerOrientV3": true, + "explorerOrientation": "side", + "explorerPaths": [], + "explorerProgressHidden": false, + "explorerRemoteThumbCacheMb": 200, + "explorerRemoteThumbs": "wifi", + "explorerRetapCollapse": true, + "explorerRootsInTree": true, + "explorerRowSpacing": "normal", + "explorerRulesV1": true, + "explorerShowHidden": false, + "explorerSort": "name", + "explorerSortByKind": false, + "explorerSortDesc": false, + "explorerSortPerFolder": false, + "explorerStyle": "xplore", + "explorerSwipePanes": false, + "explorerTextScale": 100, + "explorerToolbar": "middle", + "explorerV2": true, + "externalBrowser": "system", + "externalPlayers": {}, + "folderBookmarks": [], + "ftpServers": [], + "homeUrl": "https://www.youtube.com/", + "imageExts": [ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "bmp" + ], + "lastRunVersion": "", + "lastUpdateCheck": "", + "lastUpdateCheckVersion": "", + "liveSyncIntervalSec": 30, + "liveSyncOnStart": "auto", + "liveSyncPairs": [], + "loginCookieSites": [ + "youtube.com", + "google.com", + "instagram.com", + "x.com", + "twitter.com", + "naver.com" + ], + "maxParallelDownloads": 3, + "maxParallelJobs": 5, + "migrationNotice": true, + "minimizeToTray": true, + "mkvCompactList": "auto", + "mkvExisting": "ask", + "mkvOutputRoot": null, + "moveTargets": [], + "navOrder": [ + "mkv", + "browser", + "tor", + "explorer", + "rsync", + "downloads", + "aiimage" + ], + "openFileAction": "play", + "openFileWindow": "same", + "openSubtitlesSaved": { + "key": false, + "password": false, + "user": false + }, + "orientationV2": true, + "playlistMode": "series", + "preferredSubtitleLanguage": "", + "readerAutoSeconds": 3, + "readerBrightness": 0.0, + "readerFit": "page", + "readerGallery": false, + "readerRtl": false, + "readerSplit": "auto", + "readerSystemBars": true, + "readerVolumeKeys": false, + "recycleOnDelete": true, + "rememberPasswords": true, + "remoteIdleSeconds": 60, + "robocopyLinksV1": true, + "robocopyOptions": "/E /COPY:DAT /DCOPY:T /R:2 /W:2", + "rollbackKeepModels": "", + "rootEnabled": false, + "rsyncOptions": "-avPog", + "rsyncPath": "", + "rsyncPaths": [], + "rsyncSource": "download", + "runInBackground": true, + "screenOrientation": "auto", + "sftpServers": [], + "shizukuEnabled": false, + "showHotkey": "Ctrl+Shift+X", + "showLog": true, + "skippedVersion": "", + "smbRequireSecure": false, + "smbServers": [], + "startMenuShortcut": true, + "startScreen": "home", + "subtitleScale": 1.0, + "swipeNav": true, + "swipeWrap": true, + "syncStopToast": true, + "torHomeUrl": "https://check.torproject.org/", + "torIdleMinutes": 2, + "torJavaScript": true, + "torKeepData": false, + "torNoticeSeen": false, + "torrentStallSeconds": 60, + "tripleTapAction": "move", + "uiBrightness": "dark", + "uiLanguage": "system", + "uiLanguagesAdded": [], + "uiScale": 1.0, + "uiScaleDefault": 1.0, + "vaultAutoLockSeconds": 60, + "vaultEnabled": false, + "vaultMasterCopy": false, + "webJavaScript": true, + "webPauseOnLeave": true, + "webTranslate": false, + "webdavServers": [], + "wifiShareFolder": "", + "wifiShareMinutes": 30, + "wifiShareTailscale": true, + "youtubeAdHide": true, + "youtubeAdSkip": true, + "ytContainer": "mp4", + "ytCookiesBrowser": "*internal", + "ytCookiesFile": "", + "ytExpandPlaylists": true, + "ytPreferH264": false, + "ytQuality": "best", + "zipComic": false, + "zipComicV2": true, + "zipLevel": 6 +} \ No newline at end of file diff --git a/test/fixtures/settings_golden_in.json b/test/fixtures/settings_golden_in.json new file mode 100644 index 0000000..80eadb3 --- /dev/null +++ b/test/fixtures/settings_golden_in.json @@ -0,0 +1,246 @@ +{ + "mkvOutputRoot": null, + "moveTargets": [], + "folderBookmarks": [], + "youtubeAdSkip": false, + "youtubeAdHide": false, + "webPauseOnLeave": false, + "downloadRoot": null, + "clipboardWatch": false, + "minimizeToTray": false, + "closeAction": "x_background", + "showHotkey": "x_Ctrl+Shift+X", + "askAiOptions": false, + "addFinishedDownloads": false, + "torrentStallSeconds": 67, + "openSubtitlesSaved": { + "key": true, + "user": true, + "password": true + }, + "openSubtitlesKey": "x_", + "openSubtitlesUser": "x_", + "openSubtitlesPassword": "x_", + "ytContainer": "x_mp4", + "ytQuality": "x_best", + "ytPreferH264": true, + "ytExpandPlaylists": false, + "ytCookiesBrowser": "x_*internal", + "loginCookieSites": [ + "youtube.com", + "google.com", + "instagram.com", + "x.com", + "twitter.com", + "naver.com" + ], + "cookieScopeV2": false, + "ytCookiesFile": "x_", + "maxParallelDownloads": 10, + "maxParallelJobs": 12, + "showLog": false, + "mkvCompactList": "x_auto", + "startScreen": "x_home", + "uiScale": 1.5, + "uiScaleDefault": 1.5, + "uiBrightness": "x_dark", + "openFileAction": "x_play", + "screenOrientation": "x_auto", + "orientationV2": false, + "uiLanguage": "x_system", + "appIcon": "x_yellow", + "uiLanguagesAdded": [], + "openFileWindow": "x_same", + "homeUrl": "x_https://www.youtube.com/", + "browserEngine": "x_edge", + "cefWhenElevated": true, + "externalBrowser": "x_system", + "webTranslate": true, + "webJavaScript": false, + "dnsMode": "x_custom", + "dnsPrimary": "x_8.8.8.8", + "dnsSecondary": "x_8.8.4.4", + "dnsDohUrl": "x_https://dns.google/dns-query", + "torKeepData": true, + "torIdleMinutes": 9, + "torHomeUrl": "x_https://check.torproject.org/", + "torJavaScript": false, + "torNoticeSeen": true, + "explorerLayout": "x_dual", + "explorerOnePaneCopy": "x_clip", + "explorerRetapCollapse": false, + "explorerTextScale": 107, + "explorerRowSpacing": "x_normal", + "batchRenameLast": {}, + "explorerRootsInTree": false, + "explorerAlwaysMarks": true, + "explorerSwipePanes": true, + "androidTrashDays": 21, + "explorerClick": "x_select", + "explorerV2": false, + "explorerRulesV1": false, + "explorerStyle": "x_xplore", + "explorerOrientation": "x_side", + "explorerOrientV3": false, + "explorerToolbar": "x_middle", + "explorerButtons": [], + "explorerSort": "x_name", + "explorerSortDesc": true, + "explorerFolderSize": "x_sort", + "explorerDropAction": "x_ask", + "explorerProgressHidden": true, + "zipLevel": 13, + "wifiShareMinutes": 37, + "wifiShareTailscale": false, + "wifiShareFolder": "x_", + "explorerSortPerFolder": true, + "explorerSortByKind": true, + "explorerRemoteThumbs": "x_wifi", + "explorerRemoteThumbCacheMb": 207, + "explorerKeyOverrides": {}, + "vaultEnabled": true, + "shizukuEnabled": true, + "rootEnabled": true, + "vaultMasterCopy": true, + "vaultAutoLockSeconds": 67, + "explorerFolderSorts": {}, + "explorerShowHidden": true, + "explorerHiddenItems": [], + "explorerHideConfirm": false, + "explorerPaths": [], + "copyMethodFile": "x_builtin", + "copyConflict": "x_ask", + "mkvExisting": "x_ask", + "copySkipNewer": false, + "copyMethodFolder": "x_builtin", + "rsyncPaths": [], + "components": [ + "mkv", + "browser", + "explorer", + "rsync", + "downloads", + "aiimage", + "viewer" + ], + "aiImageV1": false, + "robocopyLinksV1": false, + "navOrder": [ + "mkv", + "browser", + "tor", + "explorer", + "rsync", + "downloads", + "aiimage" + ], + "swipeNav": false, + "imageExts": [ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "bmp" + ], + "readerFit": "x_page", + "readerRtl": true, + "readerBrightness": 0.5, + "readerSystemBars": false, + "readerAutoSeconds": 10, + "readerGallery": true, + "readerVolumeKeys": true, + "readerSplit": "x_auto", + "startMenuShortcut": false, + "syncStopToast": false, + "swipeWrap": false, + "browserSwipeHinted": true, + "cleanupPrechecked": [ + "work", + "download" + ], + "migrationNotice": false, + "allowInnerToOuter": false, + "rememberPasswords": false, + "recycleOnDelete": false, + "tripleTapAction": "x_move", + "preferredSubtitleLanguage": "x_", + "davExternalOpen": "x_ask", + "allFilesHint2": true, + "subtitleScale": 1.5, + "zipComic": true, + "zipComicV2": false, + "aiEngine": "x_local", + "aiDevice": "x_auto", + "aiAutoDevice": "x_", + "aiAutoDeviceFor": "x_", + "aiTaesd": false, + "aiWidth": 519, + "aiHeight": 519, + "aiSteps": 11, + "aiCfg": 1.5, + "aiCount": 8, + "aiStrength": 1.1, + "aiNegative": "x_", + "aiPrompt": "x_", + "aiRecent": [], + "aiSaveDir": "x_", + "aiAgreed": [], + "aiUpModel": "x_auto", + "aiUpMemory": "x_normal", + "aiUpSecPerMp": {}, + "aiUpScale": 9, + "aiQualityButton": true, + "aiQualityStrength": "x_normal", + "aiUpFormat": "x_same", + "aiUpJpgQuality": 99, + "aiUpDir": "x_", + "aiUpAfter": "x_upscaled", + "aiServices": [], + "webdavServers": [], + "sftpServers": [], + "smbServers": [], + "smbRequireSecure": true, + "remoteIdleSeconds": 67, + "ftpServers": [], + "rsyncOptions": "x_-avPog", + "robocopyOptions": "x_/E /COPY:DAT /DCOPY:T /R:2 /W:2", + "copyRunMode": "x_each", + "copyBandwidthKBps": 7, + "rsyncSource": "x_download", + "rsyncPath": "x_", + "liveSyncPairs": [], + "liveSyncIntervalSec": 37, + "runInBackground": false, + "backgroundV2": false, + "liveSyncOnStart": "x_auto", + "copyTasks": [], + "explorerHistory": [], + "autoCheckUpdates": false, + "lastUpdateCheck": "x_", + "lastUpdateCheckVersion": "x_", + "lastRunVersion": "x_", + "rollbackKeepModels": "x_", + "skippedVersion": "x_", + "playlistMode": "x_series", + "externalPlayers": {}, + "encode": { + "codec": "x_copy", + "resolution": "x_original", + "quality": "x_normal", + "frame": "x_original", + "fit": "x_fill", + "rotate": "x_none", + "brightness": 7, + "contrast": 7, + "saturation": 7, + "temperature": 7 + }, + "aiSource": "x_und", + "aiTargets": [ + "ko", + "en", + "ja" + ], + "aiWhisper": "x_whisper-base" +} \ No newline at end of file diff --git a/test/fixtures/settings_golden_out.json b/test/fixtures/settings_golden_out.json new file mode 100644 index 0000000..b2fa662 --- /dev/null +++ b/test/fixtures/settings_golden_out.json @@ -0,0 +1,243 @@ +{ + "addFinishedDownloads": false, + "aiAgreed": [], + "aiAutoDevice": "x_", + "aiAutoDeviceFor": "x_", + "aiCfg": 1.5, + "aiCount": 8, + "aiDevice": "x_auto", + "aiEngine": "x_local", + "aiHeight": 519, + "aiImageV1": true, + "aiNegative": "x_", + "aiPrompt": "x_", + "aiQualityButton": true, + "aiQualityStrength": "normal", + "aiRecent": [], + "aiSaveDir": "x_", + "aiServices": [], + "aiSource": "x_und", + "aiSteps": 11, + "aiStrength": 1.0, + "aiTaesd": false, + "aiTargets": [ + "ko", + "en", + "ja" + ], + "aiUpAfter": "upscaled", + "aiUpDir": "x_", + "aiUpFormat": "same", + "aiUpJpgQuality": 99, + "aiUpMemory": "normal", + "aiUpModel": "auto", + "aiUpScale": 2, + "aiUpSecPerMp": {}, + "aiWhisper": "x_whisper-base", + "aiWidth": 519, + "allFilesHint2": true, + "allowInnerToOuter": false, + "androidTrashDays": 21, + "appIcon": "yellow", + "askAiOptions": false, + "autoCheckUpdates": false, + "backgroundV2": true, + "batchRenameLast": {}, + "browserEngine": "x_edge", + "browserSwipeHinted": true, + "cefWhenElevated": true, + "cleanupPrechecked": [ + "work", + "download" + ], + "clipboardWatch": false, + "closeAction": "background", + "components": [ + "mkv", + "browser", + "explorer", + "rsync", + "downloads", + "aiimage", + "viewer" + ], + "cookieScopeV2": true, + "copyBandwidthKBps": 7, + "copyConflict": "ask", + "copyMethodFile": "builtin", + "copyMethodFolder": "builtin", + "copyRunMode": "each", + "copySkipNewer": false, + "copyTasks": [], + "davExternalOpen": "ask", + "dnsDohUrl": "https://dns.google/dns-query", + "dnsMode": "custom", + "dnsPrimary": "8.8.8.8", + "dnsSecondary": "8.8.4.4", + "downloadRoot": null, + "encode": { + "brightness": 7, + "codec": "copy", + "contrast": 7, + "fit": "fill", + "frame": "original", + "quality": "normal", + "resolution": "original", + "rotate": "none", + "saturation": 7, + "temperature": 7 + }, + "explorerAlwaysMarks": false, + "explorerButtons": [], + "explorerClick": "select", + "explorerDropAction": "ask", + "explorerFolderSize": "sort", + "explorerFolderSorts": {}, + "explorerHiddenItems": [], + "explorerHideConfirm": false, + "explorerHistory": [], + "explorerKeyOverrides": {}, + "explorerLayout": "dual", + "explorerOnePaneCopy": "clip", + "explorerOrientV3": true, + "explorerOrientation": "side", + "explorerPaths": [], + "explorerProgressHidden": true, + "explorerRemoteThumbCacheMb": 207, + "explorerRemoteThumbs": "wifi", + "explorerRetapCollapse": true, + "explorerRootsInTree": false, + "explorerRowSpacing": "normal", + "explorerRulesV1": true, + "explorerShowHidden": true, + "explorerSort": "name", + "explorerSortByKind": true, + "explorerSortDesc": true, + "explorerSortPerFolder": true, + "explorerStyle": "xplore", + "explorerSwipePanes": true, + "explorerTextScale": 107, + "explorerToolbar": "middle", + "explorerV2": true, + "externalBrowser": "x_system", + "externalPlayers": {}, + "folderBookmarks": [], + "ftpServers": [], + "homeUrl": "x_https://www.youtube.com/", + "imageExts": [ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "bmp" + ], + "lastRunVersion": "x_", + "lastUpdateCheck": "x_", + "lastUpdateCheckVersion": "x_", + "liveSyncIntervalSec": 37, + "liveSyncOnStart": "auto", + "liveSyncPairs": [], + "loginCookieSites": [ + "youtube.com", + "google.com", + "instagram.com", + "x.com", + "twitter.com", + "naver.com" + ], + "maxParallelDownloads": 10, + "maxParallelJobs": 12, + "migrationNotice": false, + "minimizeToTray": false, + "mkvCompactList": "auto", + "mkvExisting": "ask", + "mkvOutputRoot": null, + "moveTargets": [], + "navOrder": [ + "mkv", + "browser", + "tor", + "explorer", + "rsync", + "downloads", + "aiimage" + ], + "openFileAction": "play", + "openFileWindow": "same", + "openSubtitlesSaved": { + "key": true, + "password": true, + "user": true + }, + "orientationV2": true, + "playlistMode": "series", + "preferredSubtitleLanguage": "x_", + "readerAutoSeconds": 10, + "readerBrightness": 0.5, + "readerFit": "page", + "readerGallery": true, + "readerRtl": true, + "readerSplit": "auto", + "readerSystemBars": false, + "readerVolumeKeys": true, + "recycleOnDelete": false, + "rememberPasswords": false, + "remoteIdleSeconds": 60, + "robocopyLinksV1": true, + "robocopyOptions": "x_/E /COPY:DAT /DCOPY:T /R:2 /W:2", + "rollbackKeepModels": "", + "rootEnabled": true, + "rsyncOptions": "x_-avPog", + "rsyncPath": "x_", + "rsyncPaths": [], + "rsyncSource": "download", + "runInBackground": true, + "screenOrientation": "auto", + "sftpServers": [], + "shizukuEnabled": true, + "showHotkey": "x_Ctrl+Shift+X", + "showLog": false, + "skippedVersion": "x_", + "smbRequireSecure": true, + "smbServers": [], + "startMenuShortcut": false, + "startScreen": "x_home", + "subtitleScale": 1.5, + "swipeNav": false, + "swipeWrap": false, + "syncStopToast": false, + "torHomeUrl": "x_https://check.torproject.org/", + "torIdleMinutes": 9, + "torJavaScript": false, + "torKeepData": true, + "torNoticeSeen": true, + "torrentStallSeconds": 60, + "tripleTapAction": "move", + "uiBrightness": "dark", + "uiLanguage": "x_system", + "uiLanguagesAdded": [], + "uiScale": 1.5, + "uiScaleDefault": 1.5, + "vaultAutoLockSeconds": 67, + "vaultEnabled": true, + "vaultMasterCopy": true, + "webJavaScript": false, + "webPauseOnLeave": false, + "webTranslate": true, + "webdavServers": [], + "wifiShareFolder": "x_", + "wifiShareMinutes": 30, + "wifiShareTailscale": false, + "youtubeAdHide": false, + "youtubeAdSkip": false, + "ytContainer": "mp4", + "ytCookiesBrowser": "x_*internal", + "ytCookiesFile": "x_", + "ytExpandPlaylists": false, + "ytPreferH264": true, + "ytQuality": "best", + "zipComic": false, + "zipComicV2": true, + "zipLevel": 6 +} \ No newline at end of file diff --git a/test/settings_json_contract_test.dart b/test/settings_json_contract_test.dart new file mode 100644 index 0000000..f30020a --- /dev/null +++ b/test/settings_json_contract_test.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:path/path.dart' as p; + +/// 설정 A 약속 시험: 설정 파일 (settings.json) 을 읽고 다시 쓴 결과가 나누기 전 판과 같다 (키 순서를 뺀 바이트 - 키를 정렬해 견줌). +/// - 합성: 모든 키의 값을 바꾼 입력 (test/fixtures/settings_golden_in.json) → 나누기 전 판의 출력 (…_out.json) +/// - 사용자 파일: JJ_USER_SETTINGS_EXPECT 가 있으면 사용자 설정 파일 (com.jj\jj_mkvmaker\settings.json) 을 시험 폴더에 복사해 읽기만 하고, +/// 나누기 전 판이 같은 파일로 낸 출력 (scratch, 커밋하지 않음) 과 견준다. 사용자 파일에는 쓰지 않는다 +/// JJ_SETTINGS_GOLDEN_WRITE=1 이면 견주지 않고 기준을 만든다 (나누기 전 판에서 한 번) +void main() { + final write = Platform.environment['JJ_SETTINGS_GOLDEN_WRITE'] == '1'; + const fixtures = 'test/fixtures'; + + /// 키를 정렬한 JSON (키 순서는 분야를 옮기면 바뀐다 - 값 · 키 집합만 견줌) + String canonical(Object? v) { + Object? sort(Object? x) => x is Map + ? {for (final k in (x.keys.map((e) => '$e').toList()..sort())) k: sort(x[k])} + : x is List + ? [for (final e in x) sort(e)] + : x; + return const JsonEncoder.withIndent(' ').convert(sort(v)); + } + + String roundTrip(String text) => canonical(AppSettings.fromJson(jsonDecode(text) as Map).toJson()); + + /// 모든 키의 값을 바꾼 입력 (bool 은 반대, 수는 +7, 글은 앞에 x_, 안쪽 묶음도) - 읽기의 검사 · 고침도 함께 지나게 + Object? change(Object? v) => switch (v) { + final bool b => !b, + final int n => n + 7, + final double d => d + 0.5, + final String s => 'x_$s', + final Map m => {for (final e in m.entries) '${e.key}': change(e.value)}, + _ => v, + }; + Map mutated() => {for (final e in AppSettings().toJson(plainSecrets: true).entries) e.key: change(e.value)}; + + test('합성: 모든 키를 바꾼 설정을 읽고 쓰면 나누기 전 판과 같다 · 처음 값도', () { + final inFile = File(p.join(fixtures, 'settings_golden_in.json')); + final outFile = File(p.join(fixtures, 'settings_golden_out.json')); + final defFile = File(p.join(fixtures, 'settings_golden_default.json')); + if (write) { + Directory(fixtures).createSync(recursive: true); + inFile.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(mutated())); + outFile.writeAsStringSync(roundTrip(inFile.readAsStringSync())); + defFile.writeAsStringSync(canonical(AppSettings().toJson())); + return; + } + expect(roundTrip(inFile.readAsStringSync()), outFile.readAsStringSync()); + expect(canonical(AppSettings().toJson()), defFile.readAsStringSync()); + }); + + final expectPath = Platform.environment['JJ_USER_SETTINGS_EXPECT']; + test('사용자 설정 파일 (복사본): 읽고 쓰면 나누기 전 판과 같다', () { + final user = File(p.join(Platform.environment['APPDATA'] ?? '', 'com.jj', 'jj_mkvmaker', 'settings.json')); + final tmp = Directory.systemTemp.createTempSync('jj_settings_user_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final copy = user.copySync(p.join(tmp.path, 'settings.json')); + final got = roundTrip(copy.readAsStringSync()); + if (write) { + File(expectPath!).writeAsStringSync(got); + return; + } + expect(got, File(expectPath!).readAsStringSync()); + }, skip: expectPath == null ? '사용자 파일 기준 (JJ_USER_SETTINGS_EXPECT) 이 없으면 건너뜀 - 이 PC 에서만' : null); +}