설정 파일: 안전하게 저장 · 깨지면 백업으로 되살리고 알림

근거: 감독 세션 지시 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 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-08 23:54:30 +09:00
parent 951a1c927c
commit 3dba07dd62
8 changed files with 207 additions and 6 deletions

View File

@ -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)",

View File

@ -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 @@
"일시정지": "一時停止",
"읽을 수 없습니다": "読み込めません",
"읽을 수 없음": "読み取れません",
"읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "読めなかった元のファイルは削除せず、ここに保管しました:",
"임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理",
"자동": "自動",
"자동 (기본)": "自動 (既定)",

View File

@ -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 @@
"일시정지": "暂停",
"읽을 수 없습니다": "无法读取",
"읽을 수 없음": "无法读取",
"읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "无法读取的原文件没有删除,保存在这里:",
"임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片",
"자동": "自动",
"자동 (기본)": "自动 (默认)",

View File

@ -155,6 +155,12 @@ class AppController extends ChangeNotifier {
Future<void> 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');

View File

@ -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<String> _file() async =>
_path ?? p.join((await getApplicationSupportDirectory()).path, 'settings.json');
static AppSettings _parse(String text) => AppSettings.fromJson(jsonDecode(text) as Map<String, dynamic>);
Future<AppSettings> 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<String, dynamic>);
}
} 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<void> save(AppSettings s) async {
/// 한 번에 하나씩 쓴다 (빠르게 여러 번 바꿔도 파일이 섞이지 않게)
Future<void> _last = Future.value();
Future<void> save(AppSettings s) {
final text = const JsonEncoder.withIndent(' ').convert(s.toJson());
return _last = _last.catchError((_) {}).then((_) => _write(text));
}
Future<void> _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});
}

View File

@ -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<void> main(List<String> 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<void> 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 {

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../l10n/tr.dart';
/// 설정 파일을 읽지 못했으면 켤 때 알린다: 백업으로 되살렸는지 / 처음 설정으로 켰는지, 읽지 못한 파일을 둔 곳.
Future<void> showSettingsProblem(BuildContext context, AppController c) async {
final problem = c.settingsStore?.problem;
if (problem == null) return;
await showDialog<void>(
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;
}

View File

@ -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);
});
}