diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs index 63eed90..74d832d 100644 --- a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs +++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs @@ -45,6 +45,43 @@ public class AppConfigStoreTests Assert.AreEqual("{ this is not valid json", File.ReadAllText(path + ".bak")); } + [TestMethod] + public void LoadUnreadableFileReturnsDefaults() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + File.WriteAllText(path, "{ \"language\": \"zh-Hans\" }"); + var store = new AppConfigStore(path); + + AppConfig config; + using (new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + config = store.Load(); + } + + Assert.AreEqual(AppConfig.DefaultLanguage, config.Language); + Assert.IsFalse(File.Exists(path + ".bak")); + } + + [TestMethod] + public void LoadCorruptFileReturnsDefaultsWhenTheBackupCannotBeWritten() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.FullPath, "app.json"); + File.WriteAllText(path, "{ this is not valid json"); + File.WriteAllText(path + ".bak", "older backup"); + var store = new AppConfigStore(path); + + AppConfig config; + using (new FileStream(path + ".bak", FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + config = store.Load(); + } + + Assert.AreEqual(AppConfig.CurrentFormatVersion, config.Version); + Assert.AreEqual("older backup", File.ReadAllText(path + ".bak")); + } + [TestMethod] public void LoadJsonNullReturnsDefaults() { diff --git a/YKanBan/Program.cs b/YKanBan/Program.cs index 8a1170b..56f9466 100644 --- a/YKanBan/Program.cs +++ b/YKanBan/Program.cs @@ -12,9 +12,16 @@ sealed class Program { BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); - // The lifetime has ended: persist the configuration once, then release services. - App.Services?.SaveConfiguration(); - App.Services?.Dispose(); + // The lifetime has ended: persist the configuration once, then release + // services; the workspace lock and connection are released even if saving fails. + try + { + App.Services?.SaveConfiguration(); + } + finally + { + App.Services?.Dispose(); + } } // Avalonia configuration, don't remove; also used by visual designer. diff --git a/YKanBan/Services/AppServices.cs b/YKanBan/Services/AppServices.cs index 02cac8e..be70d9c 100644 --- a/YKanBan/Services/AppServices.cs +++ b/YKanBan/Services/AppServices.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using YKanBan.Storage.AppData; using YKanBan.Storage.Workspace; @@ -63,9 +64,21 @@ public sealed class AppServices : IDisposable } /// - /// Persists the configuration when this session changed a setting; called once at shutdown. + /// Persists the configuration when this session changed a setting; called + /// once at shutdown. A write failure only loses this session's settings + /// changes, the same accepted loss as a crash, so it is logged and ignored. /// - public void SaveConfiguration() => ConfigStore.SaveIfChanged(Config); + public void SaveConfiguration() + { + try + { + ConfigStore.SaveIfChanged(Config); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Debug.WriteLine(exception); + } + } /// /// Releases the workspace session, if any. diff --git a/YKanBan/Storage/AppData/AppConfigStore.cs b/YKanBan/Storage/AppData/AppConfigStore.cs index d9a18c4..66b0a2f 100644 --- a/YKanBan/Storage/AppData/AppConfigStore.cs +++ b/YKanBan/Storage/AppData/AppConfigStore.cs @@ -9,7 +9,8 @@ namespace YKanBan.Storage.AppData; /// defaults; a file that is not a JSON object is backed up to app.json.bak and /// then defaults are used; an individual invalid field (unknown enum value or /// language, out-of-range width, wrong type) falls back to its own default while -/// the other fields are kept. Saving happens only when the configuration differs +/// the other fields are kept; a file that exists but cannot be read (I/O or +/// permission error) also yields defaults. Saving happens only when the configuration differs /// from what was loaded, and writes the file directly (no atomic replace) — /// losing the last session's settings to a crash is an accepted trade-off. /// @@ -52,11 +53,12 @@ public sealed class AppConfigStore /// /// Loads the configuration and records it as the persisted baseline for /// . A missing file yields defaults; a file that - /// is not a JSON object is backed up to app.json.bak and then defaults are - /// used; invalid fields fall back to their defaults individually. + /// is not a JSON object is backed up to app.json.bak (best effort) and then + /// defaults are used; invalid fields fall back to their defaults + /// individually; a file that cannot be read yields defaults. Never throws for + /// file-system reasons, because a bad app.json must not stop the app. /// /// The loaded configuration, or defaults when none could be read. - /// The file exists but cannot be read. public AppConfig Load() { AppConfig config = Read(); @@ -108,7 +110,16 @@ public sealed class AppConfigStore return new AppConfig(); } - string json = File.ReadAllText(FilePath); + string json; + try + { + json = File.ReadAllText(FilePath); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return new AppConfig(); + } + JToken root; try { @@ -137,12 +148,20 @@ public sealed class AppConfigStore } /// - /// Keeps a copy of an unusable file for inspection, then continues with defaults. + /// Keeps a copy of an unusable file for inspection, then continues with + /// defaults; a backup that cannot be written is skipped. /// /// The default configuration. private AppConfig BackUpAndUseDefaults() { - File.Copy(FilePath, FilePath + ".bak", overwrite: true); + try + { + File.Copy(FilePath, FilePath + ".bak", overwrite: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // The backup is only a diagnostic aid; starting with defaults matters more. + } return new AppConfig(); } diff --git a/docs/PLAN.md b/docs/PLAN.md index 7a273d5..c373feb 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -16,7 +16,7 @@ - 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录) - 补充:导出转义规则、卡片列表虚拟化、模态窗口 FlowDirection、`Resources` 类名使用规则、CI osx-x64 运行器(§5、§7、§8) - 依赖:Avalonia 11.3.6 → 11.3.22,Semi.Avalonia 11.2.1.10 → 11.3.22(§7) -- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4) +- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3) - 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9) ## 1. 项目概述 @@ -142,6 +142,8 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE - 文件缺失 → 默认值启动照常运行(下次脏退出时自然创建) - JSON 本身无法解析 → 备份为 `app.json.bak`(覆盖旧备份)→ 以默认值启动 - JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等)→ **仅该字段回退默认值**,其余字段保留,不生成备份 + - 文件存在但无法读取(IO / 权限错误)→ 以默认值启动;`.bak` 备份写不出时跳过备份,照常以默认值启动 +- 退出写盘失败(IO / 权限错误)→ 忽略,等同丢失当次会话的设置变更;无论写盘成败都释放工作区锁与连接 ### 4.4 全窗口错误页体系