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 全窗口错误页体系