설정 파일: 안전하게 저장 · 깨지면 백업으로 되살리고 알림
근거: 감독 세션 지시 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
parent
951a1c927c
commit
3dba07dd62
|
|
@ -670,9 +670,15 @@
|
||||||
"선택한 파일 재생{0}": "Play selected files{0}",
|
"선택한 파일 재생{0}": "Play selected files{0}",
|
||||||
"설정 되살리기": "Restore settings",
|
"설정 되살리기": "Restore settings",
|
||||||
"설정 반영 후 실행": "Apply and run",
|
"설정 반영 후 실행": "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).",
|
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "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.",
|
"설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.": "Settings restored. The app will close - please open it again.",
|
||||||
"설정을 되살리지 못했습니다: {0}": "Could not restore settings: {0}",
|
"설정을 되살리지 못했습니다: {0}": "Could not restore settings: {0}",
|
||||||
|
"설정을 백업으로 되살렸습니다": "Settings restored from backup",
|
||||||
"설정을 보관하지 못했습니다: {0}": "Could not keep settings: {0}",
|
"설정을 보관하지 못했습니다: {0}": "Could not keep settings: {0}",
|
||||||
"설치 계속": "Continue install",
|
"설치 계속": "Continue install",
|
||||||
"설치 실패": "Install failed",
|
"설치 실패": "Install failed",
|
||||||
|
|
@ -857,6 +863,7 @@
|
||||||
"원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).",
|
"원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).",
|
||||||
"원어: {0} ({1})": "Source: {0} ({1})",
|
"원어: {0} ({1})": "Source: {0} ({1})",
|
||||||
"원어: 자동 감지 (글자로 추정)": "Source: auto-detect (guessed from the text)",
|
"원어: 자동 감지 (글자로 추정)": "Source: auto-detect (guessed from the text)",
|
||||||
|
"원인: {0}": "Cause: {0}",
|
||||||
"월": "Mon",
|
"월": "Mon",
|
||||||
"웹 브라우저": "Web browser",
|
"웹 브라우저": "Web browser",
|
||||||
"웹 브라우저 (지금 여기)": "Web browser (you are here)",
|
"웹 브라우저 (지금 여기)": "Web browser (you are here)",
|
||||||
|
|
@ -962,6 +969,7 @@
|
||||||
"일시정지": "Pause",
|
"일시정지": "Pause",
|
||||||
"읽을 수 없습니다": "Cannot read",
|
"읽을 수 없습니다": "Cannot read",
|
||||||
"읽을 수 없음": "Cannot read",
|
"읽을 수 없음": "Cannot read",
|
||||||
|
"읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "The original file that could not be read was not deleted; it is kept here:",
|
||||||
"임시 파일 · 남은 조각 정리": "Clean temporary files · leftovers",
|
"임시 파일 · 남은 조각 정리": "Clean temporary files · leftovers",
|
||||||
"자동": "Auto",
|
"자동": "Auto",
|
||||||
"자동 (기본)": "Auto (default)",
|
"자동 (기본)": "Auto (default)",
|
||||||
|
|
|
||||||
|
|
@ -670,9 +670,15 @@
|
||||||
"선택한 파일 재생{0}": "選択したファイルを再生{0}",
|
"선택한 파일 재생{0}": "選択したファイルを再生{0}",
|
||||||
"설정 되살리기": "設定の復元",
|
"설정 되살리기": "設定の復元",
|
||||||
"설정 반영 후 실행": "反映して実行",
|
"설정 반영 후 실행": "反映して実行",
|
||||||
|
"설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}": "設定ファイルを読めずバックアップから復元 (保管: {0}): {1}",
|
||||||
|
"설정 파일을 읽지 못해 처음 설정으로 켬 (보관: {0}): {1}": "設定ファイルを読めず初期設定で起動 (保管: {0}): {1}",
|
||||||
|
"설정 파일을 읽지 못했습니다": "設定ファイルを読み込めませんでした",
|
||||||
|
"설정 파일이 깨져 있고 백업도 없어 처음 설정으로 켰습니다. WebDAV 서버 · 동기화 쌍 · 언어 등을 다시 확인하세요.": "設定ファイルが壊れていてバックアップもないため、初期設定で起動しました。WebDAV サーバー・同期ペア・言語などを確認してください。",
|
||||||
|
"설정 파일이 깨져 있어, 마지막으로 정상 저장된 설정 (백업) 으로 켰습니다. 그 뒤에 바꾼 것만 다시 확인하세요.": "設定ファイルが壊れていたため、最後に正しく保存された設定 (バックアップ) で起動しました。その後に変えたものだけ確認してください。",
|
||||||
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "設定画面で「この提供元のアプリを許可」をオンにしてから戻り、[インストールを続ける] を押してください。ダウンロードしたファイルをそのまま使います (再ダウンロードしません)。",
|
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "設定画面で「この提供元のアプリを許可」をオンにしてから戻り、[インストールを続ける] を押してください。ダウンロードしたファイルをそのまま使います (再ダウンロードしません)。",
|
||||||
"설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.": "設定を復元しました。アプリを終了します - もう一度起動してください。",
|
"설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.": "設定を復元しました。アプリを終了します - もう一度起動してください。",
|
||||||
"설정을 되살리지 못했습니다: {0}": "設定を復元できませんでした: {0}",
|
"설정을 되살리지 못했습니다: {0}": "設定を復元できませんでした: {0}",
|
||||||
|
"설정을 백업으로 되살렸습니다": "設定をバックアップから復元しました",
|
||||||
"설정을 보관하지 못했습니다: {0}": "設定を保管できませんでした: {0}",
|
"설정을 보관하지 못했습니다: {0}": "設定を保管できませんでした: {0}",
|
||||||
"설치 계속": "インストールを続ける",
|
"설치 계속": "インストールを続ける",
|
||||||
"설치 실패": "インストール失敗",
|
"설치 실패": "インストール失敗",
|
||||||
|
|
@ -857,6 +863,7 @@
|
||||||
"원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。",
|
"원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。",
|
||||||
"원어: {0} ({1})": "原語: {0}({1})",
|
"원어: {0} ({1})": "原語: {0}({1})",
|
||||||
"원어: 자동 감지 (글자로 추정)": "原語: 自動検出(文字から推定)",
|
"원어: 자동 감지 (글자로 추정)": "原語: 自動検出(文字から推定)",
|
||||||
|
"원인: {0}": "原因: {0}",
|
||||||
"월": "月",
|
"월": "月",
|
||||||
"웹 브라우저": "ウェブブラウザー",
|
"웹 브라우저": "ウェブブラウザー",
|
||||||
"웹 브라우저 (지금 여기)": "ウェブブラウザー(現在地)",
|
"웹 브라우저 (지금 여기)": "ウェブブラウザー(現在地)",
|
||||||
|
|
@ -962,6 +969,7 @@
|
||||||
"일시정지": "一時停止",
|
"일시정지": "一時停止",
|
||||||
"읽을 수 없습니다": "読み込めません",
|
"읽을 수 없습니다": "読み込めません",
|
||||||
"읽을 수 없음": "読み取れません",
|
"읽을 수 없음": "読み取れません",
|
||||||
|
"읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "読めなかった元のファイルは削除せず、ここに保管しました:",
|
||||||
"임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理",
|
"임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理",
|
||||||
"자동": "自動",
|
"자동": "自動",
|
||||||
"자동 (기본)": "自動 (既定)",
|
"자동 (기본)": "自動 (既定)",
|
||||||
|
|
|
||||||
|
|
@ -670,9 +670,15 @@
|
||||||
"선택한 파일 재생{0}": "播放所选文件{0}",
|
"선택한 파일 재생{0}": "播放所选文件{0}",
|
||||||
"설정 되살리기": "恢复设置",
|
"설정 되살리기": "恢复设置",
|
||||||
"설정 반영 후 실행": "应用后运行",
|
"설정 반영 후 실행": "应用后运行",
|
||||||
|
"설정 파일을 읽지 못해 백업으로 되살림 (보관: {0}): {1}": "无法读取设置文件,已从备份恢复 (保存于: {0}): {1}",
|
||||||
|
"설정 파일을 읽지 못해 처음 설정으로 켬 (보관: {0}): {1}": "无法读取设置文件,已以初始设置启动 (保存于: {0}): {1}",
|
||||||
|
"설정 파일을 읽지 못했습니다": "无法读取设置文件",
|
||||||
|
"설정 파일이 깨져 있고 백업도 없어 처음 설정으로 켰습니다. WebDAV 서버 · 동기화 쌍 · 언어 등을 다시 확인하세요.": "设置文件已损坏且没有备份,已以初始设置启动。请重新检查 WebDAV 服务器、同步对、语言等。",
|
||||||
|
"설정 파일이 깨져 있어, 마지막으로 정상 저장된 설정 (백업) 으로 켰습니다. 그 뒤에 바꾼 것만 다시 확인하세요.": "设置文件已损坏,已以最后一次正常保存的设置 (备份) 启动。请只检查之后更改的内容。",
|
||||||
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "请在设置页面开启\"允许来自此来源的应用\",然后返回并点按 [继续安装]。将直接使用已下载的文件 (不会重新下载)。",
|
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "请在设置页面开启\"允许来自此来源的应用\",然后返回并点按 [继续安装]。将直接使用已下载的文件 (不会重新下载)。",
|
||||||
"설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.": "设置已恢复。应用将关闭 - 请重新打开。",
|
"설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.": "设置已恢复。应用将关闭 - 请重新打开。",
|
||||||
"설정을 되살리지 못했습니다: {0}": "无法恢复设置: {0}",
|
"설정을 되살리지 못했습니다: {0}": "无法恢复设置: {0}",
|
||||||
|
"설정을 백업으로 되살렸습니다": "已从备份恢复设置",
|
||||||
"설정을 보관하지 못했습니다: {0}": "无法保存设置: {0}",
|
"설정을 보관하지 못했습니다: {0}": "无法保存设置: {0}",
|
||||||
"설치 계속": "继续安装",
|
"설치 계속": "继续安装",
|
||||||
"설치 실패": "安装失败",
|
"설치 실패": "安装失败",
|
||||||
|
|
@ -857,6 +863,7 @@
|
||||||
"원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。",
|
"원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。",
|
||||||
"원어: {0} ({1})": "原语言: {0}({1})",
|
"원어: {0} ({1})": "原语言: {0}({1})",
|
||||||
"원어: 자동 감지 (글자로 추정)": "原语言: 自动检测(根据文字推测)",
|
"원어: 자동 감지 (글자로 추정)": "原语言: 自动检测(根据文字推测)",
|
||||||
|
"원인: {0}": "原因: {0}",
|
||||||
"월": "一",
|
"월": "一",
|
||||||
"웹 브라우저": "网页浏览器",
|
"웹 브라우저": "网页浏览器",
|
||||||
"웹 브라우저 (지금 여기)": "网页浏览器(当前位置)",
|
"웹 브라우저 (지금 여기)": "网页浏览器(当前位置)",
|
||||||
|
|
@ -962,6 +969,7 @@
|
||||||
"일시정지": "暂停",
|
"일시정지": "暂停",
|
||||||
"읽을 수 없습니다": "无法读取",
|
"읽을 수 없습니다": "无法读取",
|
||||||
"읽을 수 없음": "无法读取",
|
"읽을 수 없음": "无法读取",
|
||||||
|
"읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "无法读取的原文件没有删除,保存在这里:",
|
||||||
"임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片",
|
"임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片",
|
||||||
"자동": "自动",
|
"자동": "自动",
|
||||||
"자동 (기본)": "自动 (默认)",
|
"자동 (기본)": "自动 (默认)",
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,12 @@ class AppController extends ChangeNotifier {
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
if (settingsStore != null) {
|
if (settingsStore != null) {
|
||||||
settings = await settingsStore!.load();
|
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 가 같은 쿠키를 읽음)
|
// 앱 안 브라우저의 로그인 · 쿠키 폴더 (yt-dlp 가 같은 쿠키를 읽음)
|
||||||
try {
|
try {
|
||||||
settings.webViewDataDir = p.join((await getApplicationSupportDirectory()).path, 'webview');
|
settings.webViewDataDir = p.join((await getApplicationSupportDirectory()).path, 'webview');
|
||||||
|
|
|
||||||
|
|
@ -704,33 +704,106 @@ class AppSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 설정 파일 읽기·쓰기
|
/// 설정 파일 읽기·쓰기
|
||||||
|
/// 설정 파일 읽기 · 쓰기.
|
||||||
|
/// - 쓰기: 임시 파일에 다 쓴 뒤 바꿔치기 (쓰는 도중 앱이 끝나도 settings.json 이 깨지지 않게).
|
||||||
|
/// 바꿔치기 전의 정상 파일은 settings.json.bak 으로 남긴다.
|
||||||
|
/// - 읽기: settings.json 을 읽지 못하면 settings.json.bak 으로 되살린다. 읽지 못한 파일은 지우지 않고
|
||||||
|
/// settings.broken-(시각).json 으로 보관한다 (다음 저장에 덮여 사라지지 않게). 무슨 일이 있었는지는 [problem].
|
||||||
class SettingsStore {
|
class SettingsStore {
|
||||||
final String? _path;
|
final String? _path;
|
||||||
|
|
||||||
SettingsStore([this._path]);
|
SettingsStore([this._path]);
|
||||||
|
|
||||||
|
/// 마지막 [load] 에서 생긴 일 (null = 문제 없음). 앱이 켜질 때 사용자에게 알린다.
|
||||||
|
SettingsLoadProblem? problem;
|
||||||
|
|
||||||
Future<String> _file() async =>
|
Future<String> _file() async =>
|
||||||
_path ?? p.join((await getApplicationSupportDirectory()).path, 'settings.json');
|
_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 {
|
Future<AppSettings> load() async {
|
||||||
try {
|
problem = null;
|
||||||
final f = File(await _file());
|
final f = File(await _file());
|
||||||
|
try {
|
||||||
// 이전 이름(jj_capcut) 의 설정 파일이 있으면 옮겨 온다
|
// 이전 이름(jj_capcut) 의 설정 파일이 있으면 옮겨 온다
|
||||||
final legacy = File(p.join(p.dirname(f.parent.path), 'jj_capcut', 'settings.json'));
|
final legacy = File(p.join(p.dirname(f.parent.path), 'jj_capcut', 'settings.json'));
|
||||||
if (_path == null && !await f.exists() && await legacy.exists()) {
|
if (_path == null && !await f.exists() && await legacy.exists()) {
|
||||||
await f.parent.create(recursive: true);
|
await f.parent.create(recursive: true);
|
||||||
await legacy.copy(f.path);
|
await legacy.copy(f.path);
|
||||||
}
|
}
|
||||||
if (await f.exists()) {
|
|
||||||
return AppSettings.fromJson(jsonDecode(await f.readAsString()) as Map<String, dynamic>);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
} 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();
|
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());
|
final f = File(await _file());
|
||||||
await f.parent.create(recursive: true);
|
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});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'app/app_controller.dart';
|
||||||
import 'app/live_sync.dart';
|
import 'app/live_sync.dart';
|
||||||
import 'app/version_snapshot.dart';
|
import 'app/version_snapshot.dart';
|
||||||
import 'app/component_store.dart';
|
import 'app/component_store.dart';
|
||||||
|
import 'ui/settings_problem.dart';
|
||||||
import 'app/bookmarks_controller.dart';
|
import 'app/bookmarks_controller.dart';
|
||||||
import 'app/download_manager.dart';
|
import 'app/download_manager.dart';
|
||||||
import 'app/settings.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));
|
unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks));
|
||||||
}
|
}
|
||||||
if (controller.settings.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live);
|
if (controller.settings.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live);
|
||||||
|
// 설정 파일을 읽지 못했으면 (백업으로 되살렸거나 처음 설정으로 켰으면) 알린다
|
||||||
|
if (ctx.mounted) await showSettingsProblem(ctx, controller);
|
||||||
// 다른 버전을 쓰다가 이 버전으로 돌아왔으면 보관해 둔 이 버전의 설정을 되살릴지
|
// 다른 버전을 쓰다가 이 버전으로 돌아왔으면 보관해 둔 이 버전의 설정을 되살릴지
|
||||||
final current = await _currentVersion(services);
|
final current = await _currentVersion(services);
|
||||||
if (ctx.mounted) {
|
if (ctx.mounted) {
|
||||||
|
|
@ -491,6 +494,7 @@ Future<void> runAndroid(String dataDir) async {
|
||||||
// 다른 버전을 쓰다가 돌아왔거나 앱을 다시 설치했으면 보관해 둔 설정을 되살릴지 (되살리면 앱을 끝내고 다시 켜 달라고)
|
// 다른 버전을 쓰다가 돌아왔거나 앱을 다시 설치했으면 보관해 둔 설정을 되살릴지 (되살리면 앱을 끝내고 다시 켜 달라고)
|
||||||
final current = await _currentVersion(services);
|
final current = await _currentVersion(services);
|
||||||
final ctxV = navigatorKey.currentContext;
|
final ctxV = navigatorKey.currentContext;
|
||||||
|
if (ctxV != null && ctxV.mounted) await showSettingsProblem(ctxV, controller);
|
||||||
if (ctxV != null && ctxV.mounted) {
|
if (ctxV != null && ctxV.mounted) {
|
||||||
await checkVersionRestore(ctxV, controller, current: current, freshInstall: freshInstall,
|
await checkVersionRestore(ctxV, controller, current: current, freshInstall: freshInstall,
|
||||||
restart: () async {
|
restart: () async {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue