From 3dba07dd62e25d479478f30e4d37e346f51723ad Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:54:30 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC:=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=ED=95=98=EA=B2=8C=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=C2=B7=20=EA=B9=A8=EC=A7=80=EB=A9=B4=20=EB=B0=B1=EC=97=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=90=98=EC=82=B4=EB=A6=AC=EA=B3=A0=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 근거: 감독 세션 지시 20번 (설정 파일을 읽다 실패하면 말없이 모든 설정을 처음 상태로 켜고, 무엇이든 바꾸면 원래 설정이 영영 사라짐). - 저장: settings.json.tmp 에 다 쓴 뒤 바꿔치기 (쓰는 도중 앱이 끝나도 깨지지 않게), 한 번에 하나씩. 바꿔치기 전의 정상 파일은 settings.json.bak. - 읽기 실패: 깨진 파일은 settings.broken-(시각).json 으로 보관 (다음 저장에 덮이지 않게), .bak 으로 되살림. settings.json 만 없으면 (바꿔치기 도중 끝남) .bak 으로. 백업도 없으면 처음 설정. - 켤 때 알림 창 (Windows · Android): 백업으로 되살렸는지 / 처음 설정인지, 보관한 파일 위치, 원인. 작업 기록에도. - 시험: settings_store_test (바꿔치기 · .bak · 깨진 파일 · 백업 없음 · .bak 만 있음). Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 8 ++++ assets/l10n/ja.json | 8 ++++ assets/l10n/zh-Hans.json | 8 ++++ lib/app/app_controller.dart | 6 +++ lib/app/settings.dart | 85 ++++++++++++++++++++++++++++++++--- lib/main.dart | 4 ++ lib/ui/settings_problem.dart | 32 +++++++++++++ test/settings_store_test.dart | 62 +++++++++++++++++++++++++ 8 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 lib/ui/settings_problem.dart create mode 100644 test/settings_store_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 5d4d1fc..9094104 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -670,9 +670,15 @@ "선택한 파일 재생{0}": "Play selected files{0}", "설정 되살리기": "Restore settings", "설정 반영 후 실행": "Apply and run", + "설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}": "Could not read the settings file; restored from backup (kept: {0}): {1}", + "설정 파일을 읽지 못해 처음 설정으로 켬 (보관: {0}): {1}": "Could not read the settings file; started with default settings (kept: {0}): {1}", + "설정 파일을 읽지 못했습니다": "Could not read the settings file", + "설정 파일이 깨져 있고 백업도 없어 처음 설정으로 켰습니다. WebDAV 서버 · 동기화 쌍 · 언어 등을 다시 확인하세요.": "The settings file was damaged and there was no backup, so the app started with default settings. Please check WebDAV servers, sync pairs, language and so on.", + "설정 파일이 깨져 있어, 마지막으로 정상 저장된 설정 (백업) 으로 켰습니다. 그 뒤에 바꾼 것만 다시 확인하세요.": "The settings file was damaged, so the app started with the last correctly saved settings (backup). Please check only what you changed after that.", "설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "Turn on \"Allow from this source\" in the settings screen, then come back and tap [Continue install]. The downloaded file is used as is (no download again).", "설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.": "Settings restored. The app will close - please open it again.", "설정을 되살리지 못했습니다: {0}": "Could not restore settings: {0}", + "설정을 백업으로 되살렸습니다": "Settings restored from backup", "설정을 보관하지 못했습니다: {0}": "Could not keep settings: {0}", "설치 계속": "Continue install", "설치 실패": "Install failed", @@ -857,6 +863,7 @@ "원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).", "원어: {0} ({1})": "Source: {0} ({1})", "원어: 자동 감지 (글자로 추정)": "Source: auto-detect (guessed from the text)", + "원인: {0}": "Cause: {0}", "월": "Mon", "웹 브라우저": "Web browser", "웹 브라우저 (지금 여기)": "Web browser (you are here)", @@ -962,6 +969,7 @@ "일시정지": "Pause", "읽을 수 없습니다": "Cannot read", "읽을 수 없음": "Cannot read", + "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "The original file that could not be read was not deleted; it is kept here:", "임시 파일 · 남은 조각 정리": "Clean temporary files · leftovers", "자동": "Auto", "자동 (기본)": "Auto (default)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 400c343..1b489c2 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -670,9 +670,15 @@ "선택한 파일 재생{0}": "選択したファイルを再生{0}", "설정 되살리기": "設定の復元", "설정 반영 후 실행": "反映して実行", + "설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}": "設定ファイルを読めずバックアップから復元 (保管: {0}): {1}", + "설정 파일을 읽지 못해 처음 설정으로 켬 (보관: {0}): {1}": "設定ファイルを読めず初期設定で起動 (保管: {0}): {1}", + "설정 파일을 읽지 못했습니다": "設定ファイルを読み込めませんでした", + "설정 파일이 깨져 있고 백업도 없어 처음 설정으로 켰습니다. WebDAV 서버 · 동기화 쌍 · 언어 등을 다시 확인하세요.": "設定ファイルが壊れていてバックアップもないため、初期設定で起動しました。WebDAV サーバー・同期ペア・言語などを確認してください。", + "설정 파일이 깨져 있어, 마지막으로 정상 저장된 설정 (백업) 으로 켰습니다. 그 뒤에 바꾼 것만 다시 확인하세요.": "設定ファイルが壊れていたため、最後に正しく保存された設定 (バックアップ) で起動しました。その後に変えたものだけ確認してください。", "설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "設定画面で「この提供元のアプリを許可」をオンにしてから戻り、[インストールを続ける] を押してください。ダウンロードしたファイルをそのまま使います (再ダウンロードしません)。", "설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.": "設定を復元しました。アプリを終了します - もう一度起動してください。", "설정을 되살리지 못했습니다: {0}": "設定を復元できませんでした: {0}", + "설정을 백업으로 되살렸습니다": "設定をバックアップから復元しました", "설정을 보관하지 못했습니다: {0}": "設定を保管できませんでした: {0}", "설치 계속": "インストールを続ける", "설치 실패": "インストール失敗", @@ -857,6 +863,7 @@ "원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。", "원어: {0} ({1})": "原語: {0}({1})", "원어: 자동 감지 (글자로 추정)": "原語: 自動検出(文字から推定)", + "원인: {0}": "原因: {0}", "월": "月", "웹 브라우저": "ウェブブラウザー", "웹 브라우저 (지금 여기)": "ウェブブラウザー(現在地)", @@ -962,6 +969,7 @@ "일시정지": "一時停止", "읽을 수 없습니다": "読み込めません", "읽을 수 없음": "読み取れません", + "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "読めなかった元のファイルは削除せず、ここに保管しました:", "임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理", "자동": "自動", "자동 (기본)": "自動 (既定)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 788f173..f3ed890 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -670,9 +670,15 @@ "선택한 파일 재생{0}": "播放所选文件{0}", "설정 되살리기": "恢复设置", "설정 반영 후 실행": "应用后运行", + "설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}": "无法读取设置文件,已从备份恢复 (保存于: {0}): {1}", + "설정 파일을 읽지 못해 처음 설정으로 켬 (보관: {0}): {1}": "无法读取设置文件,已以初始设置启动 (保存于: {0}): {1}", + "설정 파일을 읽지 못했습니다": "无法读取设置文件", + "설정 파일이 깨져 있고 백업도 없어 처음 설정으로 켰습니다. WebDAV 서버 · 동기화 쌍 · 언어 등을 다시 확인하세요.": "设置文件已损坏且没有备份,已以初始设置启动。请重新检查 WebDAV 服务器、同步对、语言等。", + "설정 파일이 깨져 있어, 마지막으로 정상 저장된 설정 (백업) 으로 켰습니다. 그 뒤에 바꾼 것만 다시 확인하세요.": "设置文件已损坏,已以最后一次正常保存的设置 (备份) 启动。请只检查之后更改的内容。", "설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "请在设置页面开启\"允许来自此来源的应用\",然后返回并点按 [继续安装]。将直接使用已下载的文件 (不会重新下载)。", "설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.": "设置已恢复。应用将关闭 - 请重新打开。", "설정을 되살리지 못했습니다: {0}": "无法恢复设置: {0}", + "설정을 백업으로 되살렸습니다": "已从备份恢复设置", "설정을 보관하지 못했습니다: {0}": "无法保存设置: {0}", "설치 계속": "继续安装", "설치 실패": "安装失败", @@ -857,6 +863,7 @@ "원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。", "원어: {0} ({1})": "原语言: {0}({1})", "원어: 자동 감지 (글자로 추정)": "原语言: 自动检测(根据文字推测)", + "원인: {0}": "原因: {0}", "월": "一", "웹 브라우저": "网页浏览器", "웹 브라우저 (지금 여기)": "网页浏览器(当前位置)", @@ -962,6 +969,7 @@ "일시정지": "暂停", "읽을 수 없습니다": "无法读取", "읽을 수 없음": "无法读取", + "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "无法读取的原文件没有删除,保存在这里:", "임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片", "자동": "自动", "자동 (기본)": "自动 (默认)", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 6f2507a..afba180 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -155,6 +155,12 @@ class AppController extends ChangeNotifier { Future init() async { if (settingsStore != null) { settings = await settingsStore!.load(); + final problem = settingsStore!.problem; + if (problem != null) { + _log(problem.restoredFromBackup + ? trf('설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}', [problem.brokenCopy ?? '-', problem.error]) + : trf('설정 파일을 읽지 못해 처음 설정으로 켬 (보관: {0}): {1}', [problem.brokenCopy ?? '-', problem.error])); + } // 앱 안 브라우저의 로그인 · 쿠키 폴더 (yt-dlp 가 같은 쿠키를 읽음) try { settings.webViewDataDir = p.join((await getApplicationSupportDirectory()).path, 'webview'); diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 39accc9..07b64b9 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -704,33 +704,106 @@ class AppSettings { } /// 설정 파일 읽기·쓰기 +/// 설정 파일 읽기 · 쓰기. +/// - 쓰기: 임시 파일에 다 쓴 뒤 바꿔치기 (쓰는 도중 앱이 끝나도 settings.json 이 깨지지 않게). +/// 바꿔치기 전의 정상 파일은 settings.json.bak 으로 남긴다. +/// - 읽기: settings.json 을 읽지 못하면 settings.json.bak 으로 되살린다. 읽지 못한 파일은 지우지 않고 +/// settings.broken-(시각).json 으로 보관한다 (다음 저장에 덮여 사라지지 않게). 무슨 일이 있었는지는 [problem]. class SettingsStore { final String? _path; SettingsStore([this._path]); + /// 마지막 [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 { + problem = null; + final f = File(await _file()); try { - final f = File(await _file()); // 이전 이름(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); } - if (await f.exists()) { - return AppSettings.fromJson(jsonDecode(await f.readAsString()) as Map); - } } 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: 'settings.json 없음'); + 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'); + await f.copy(kept); + } catch (_) { + kept = null; + } + if (await bak.exists()) { + try { + final s = _parse(await bak.readAsString()); + problem = SettingsLoadProblem(restoredFromBackup: true, brokenCopy: kept, error: '$error'); + return s; + } catch (_) {} + } + problem = SettingsLoadProblem(restoredFromBackup: false, brokenCopy: kept, error: '$error'); return AppSettings(); } - Future save(AppSettings s) async { + /// 한 번에 하나씩 쓴다 (빠르게 여러 번 바꿔도 파일이 섞이지 않게) + Future _last = Future.value(); + + Future save(AppSettings s) { + final text = const JsonEncoder.withIndent(' ').convert(s.toJson()); + return _last = _last.catchError((_) {}).then((_) => _write(text)); + } + + Future _write(String text) async { final f = File(await _file()); await f.parent.create(recursive: true); - await f.writeAsString(const JsonEncoder.withIndent(' ').convert(s.toJson())); + final tmp = File('${f.path}.tmp'); + await tmp.writeAsString(text, flush: true); + if (await f.exists()) { + // 지금 파일이 정상이면 백업으로 남긴다 (깨진 파일로 좋은 백업을 덮지 않게) + try { + _parse(await f.readAsString()); + await f.copy('${f.path}.bak'); + } catch (_) {} + } + await tmp.rename(f.path); } } + +/// 설정 파일을 읽다 생긴 일 (앱이 켜질 때 알림) +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/main.dart b/lib/main.dart index 63a26c0..8f81eaf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'app/app_controller.dart'; import 'app/live_sync.dart'; import 'app/version_snapshot.dart'; import 'app/component_store.dart'; +import 'ui/settings_problem.dart'; import 'app/bookmarks_controller.dart'; import 'app/download_manager.dart'; import 'app/settings.dart'; @@ -303,6 +304,8 @@ Future main(List args) async { unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks)); } if (controller.settings.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live); + // 설정 파일을 읽지 못했으면 (백업으로 되살렸거나 처음 설정으로 켰으면) 알린다 + if (ctx.mounted) await showSettingsProblem(ctx, controller); // 다른 버전을 쓰다가 이 버전으로 돌아왔으면 보관해 둔 이 버전의 설정을 되살릴지 final current = await _currentVersion(services); if (ctx.mounted) { @@ -491,6 +494,7 @@ Future runAndroid(String dataDir) async { // 다른 버전을 쓰다가 돌아왔거나 앱을 다시 설치했으면 보관해 둔 설정을 되살릴지 (되살리면 앱을 끝내고 다시 켜 달라고) final current = await _currentVersion(services); final ctxV = navigatorKey.currentContext; + if (ctxV != null && ctxV.mounted) await showSettingsProblem(ctxV, controller); if (ctxV != null && ctxV.mounted) { await checkVersionRestore(ctxV, controller, current: current, freshInstall: freshInstall, restart: () async { diff --git a/lib/ui/settings_problem.dart b/lib/ui/settings_problem.dart new file mode 100644 index 0000000..f070da6 --- /dev/null +++ b/lib/ui/settings_problem.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import '../app/app_controller.dart'; +import '../l10n/tr.dart'; + +/// 설정 파일을 읽지 못했으면 켤 때 알린다: 백업으로 되살렸는지 / 처음 설정으로 켰는지, 읽지 못한 파일을 둔 곳. +Future showSettingsProblem(BuildContext context, AppController c) async { + final problem = c.settingsStore?.problem; + if (problem == null) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + icon: const Icon(Icons.warning_amber_rounded), + title: Text(problem.restoredFromBackup ? tr('설정을 백업으로 되살렸습니다') : tr('설정 파일을 읽지 못했습니다')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(problem.restoredFromBackup + ? tr('설정 파일이 깨져 있어, 마지막으로 정상 저장된 설정 (백업) 으로 켰습니다. 그 뒤에 바꾼 것만 다시 확인하세요.') + : tr('설정 파일이 깨져 있고 백업도 없어 처음 설정으로 켰습니다. WebDAV 서버 · 동기화 쌍 · 언어 등을 다시 확인하세요.')), + if (problem.brokenCopy != null) ...[ + const SizedBox(height: 10), + Text(tr('읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:'), style: const TextStyle(fontSize: 12)), + SelectableText(problem.brokenCopy!, style: const TextStyle(fontSize: 12)), + ], + const SizedBox(height: 10), + Text(trf('원인: {0}', [problem.error]), style: const TextStyle(fontSize: 11)), + ]), + actions: [FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))], + ), + ); + c.settingsStore?.problem = null; +} diff --git a/test/settings_store_test.dart b/test/settings_store_test.dart new file mode 100644 index 0000000..56e9da3 --- /dev/null +++ b/test/settings_store_test.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory dir; + late String file; + setUp(() { + dir = Directory.systemTemp.createTempSync('jj_settings_'); + file = p.join(dir.path, 'settings.json'); + }); + tearDown(() => dir.deleteSync(recursive: true)); + + test('저장: 임시 파일에 쓴 뒤 바꿔치기 · 직전 정상본은 .bak · 남는 임시 파일 없음', () async { + final store = SettingsStore(file); + await store.save(AppSettings()..homeUrl = 'https://a.example'); + await store.save(AppSettings()..homeUrl = 'https://b.example'); + expect((await SettingsStore(file).load()).homeUrl, 'https://b.example'); + expect(File('$file.bak').readAsStringSync(), contains('https://a.example')); + expect(File('$file.tmp').existsSync(), isFalse); + // 여러 번 빠르게 저장해도 마지막 것이 남는다 (순서대로) + await Future.wait([for (var i = 0; i < 5; i++) store.save(AppSettings()..homeUrl = 'https://$i.example')]); + expect((await SettingsStore(file).load()).homeUrl, 'https://4.example'); + }); + + test('깨진 설정 파일: 백업으로 되살리고, 깨진 파일은 보관 · 알림 정보', () async { + final store = SettingsStore(file); + await store.save(AppSettings()..homeUrl = 'https://good.example'); + await store.save(AppSettings()..homeUrl = 'https://good2.example'); // .bak = good + File(file).writeAsStringSync('{"homeUrl": "https://half'); // 반쯤 쓰다 끊긴 파일 + final s = await store.load(); + expect(s.homeUrl, 'https://good.example'); + expect(store.problem!.restoredFromBackup, isTrue); + final kept = store.problem!.brokenCopy!; + expect(File(kept).readAsStringSync(), '{"homeUrl": "https://half'); + // 다음 저장이 좋은 백업을 깨진 파일로 덮지 않는다 + await store.save(s); + expect(File('$file.bak').readAsStringSync(), contains('good')); + }); + + test('깨졌고 백업도 없음: 처음 설정 + 알림, 깨진 파일 보관 / settings.json 만 없으면 백업으로', () async { + File(file).writeAsStringSync('not json'); + final store = SettingsStore(file); + final s = await store.load(); + expect(s.homeUrl, AppSettings().homeUrl); + expect(store.problem!.restoredFromBackup, isFalse); + expect(File(store.problem!.brokenCopy!).readAsStringSync(), 'not json'); + + // 바꿔치기 도중 끝나 settings.json 이 없고 .bak 만 있는 경우 + File(file).deleteSync(); + File('$file.bak').writeAsStringSync('{"homeUrl": "https://bak.example"}'); + final again = SettingsStore(file); + expect((await again.load()).homeUrl, 'https://bak.example'); + expect(again.problem!.restoredFromBackup, isTrue); + // 정상이면 알림 없음 + await again.save(AppSettings()); + await again.load(); + expect(again.problem, isNull); + }); +}