diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs index c04a37f..a22b0fc 100644 --- a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs +++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs @@ -222,6 +222,13 @@ public class AppConfigStoreTests [DataRow("\"column-width\": { \"custom-pixels\": \"wide\" }")] [DataRow("\"confirmations\": { \"delete-card\": \"nope\" }")] [DataRow("\"version\": \"one\"")] + [DataRow("\"version\": \"1\"")] + [DataRow("\"language\": [\"zh-Hans\"]")] + [DataRow("\"confirmations\": { \"delete-card\": 0 }")] + [DataRow("\"confirmations\": { \"delete-card\": \"false\" }")] + [DataRow("\"column-width\": { \"custom-pixels\": \"500\" }")] + [DataRow("\"column-width\": { \"custom-pixels\": 500.0 }")] + [DataRow("\"column-width\": { \"custom-pixels\": 99999999999 }")] public void InvalidFieldFallsBackAloneWithoutBackup(string invalidMember) { using var directory = new TempDirectory(); diff --git a/YKanBan/Storage/AppData/AppConfigStore.cs b/YKanBan/Storage/AppData/AppConfigStore.cs index 5cf8446..29edb52 100644 --- a/YKanBan/Storage/AppData/AppConfigStore.cs +++ b/YKanBan/Storage/AppData/AppConfigStore.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; @@ -8,7 +9,7 @@ namespace YKanBan.Storage.AppData; /// Loads and saves app.json with graceful degradation: 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; an individual invalid field (unknown enum value or -/// language, out-of-range width, wrong type) falls back to its own default while +/// language, out-of-range width, wrong JSON type — no implicit conversion) falls back to its own default while /// 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 this session /// changed a setting (), and writes the file directly (no atomic replace) — @@ -29,7 +30,7 @@ public sealed class AppConfigStore private static readonly JsonSerializer FieldTolerantSerializer = JsonSerializer.Create(new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, - Converters = { new StringEnumConverter { AllowIntegerValues = false } }, + Converters = { new StringEnumConverter { AllowIntegerValues = false }, new StrictPrimitiveConverter() }, // The JSON text is already known to be well formed here, so every error is a // single member that fails to convert: skip it and keep that member's default. @@ -211,6 +212,41 @@ public sealed class AppConfigStore File.WriteAllText(FilePath, json); } + /// + /// Reads booleans, integers and strings only from JSON tokens of exactly that + /// type, so a wrong type is an invalid field (falling back to its default) + /// instead of being converted: 0 or "false" is not a boolean, + /// "500" or 500.0 is not an integer, 5 is not a string. + /// + private sealed class StrictPrimitiveConverter : JsonConverter + { + /// + public override bool CanWrite => false; + + /// + public override bool CanConvert(Type objectType) => + objectType == typeof(bool) || objectType == typeof(int) || objectType == typeof(string); + + /// + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + JsonToken expected = objectType == typeof(bool) ? JsonToken.Boolean + : objectType == typeof(int) ? JsonToken.Integer + : JsonToken.String; + if (reader.TokenType != expected) + { + throw new JsonSerializationException($"Expected a {expected} token for {objectType.Name}, got {reader.TokenType}."); + } + + // Integer tokens are read as Int64 (or BigInteger); a value outside the Int32 range is invalid too. + return objectType == typeof(int) ? checked((int)Convert.ToInt64(reader.Value, CultureInfo.InvariantCulture)) : reader.Value; + } + + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) => + throw new NotSupportedException(); + } + /// /// Serializes a configuration in the on-disk format. /// diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index e9868ed..7bb0fba 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -44,7 +44,7 @@ - [ ] 删除 `app.json`,打开设置、不改任何项直接确认后退出 → `app.json` 不被创建;改一项再改回原值后退出 → `app.json` 被写出(脏标记按"改动过"判定) - [ ] 处于错误页(例如参数错误页)的实例退出时不写 `app.json`(可先删除 `app.json`,退出后确认文件未被创建) - [ ] `app.json` 写成非法 JSON → 启动正常、使用默认值,生成 `app.json.bak`;改一次设置退出后 `app.json` 恢复为合法内容 -- [ ] `app.json` 中单个字段非法(如 `"theme": "pink"`、列宽 `"custom-pixels": 9999`)→ 仅该字段回退默认,其余设置保留,不生成 `.bak` +- [ ] `app.json` 中单个字段非法(如 `"theme": "pink"`、列宽 `"custom-pixels": 9999`、类型不符如 `"delete-card": 0`、`"custom-pixels": "500"`)→ 仅该字段回退默认,其余设置保留,不生成 `.bak` ## B. 启动与错误页(M4 / M4R) diff --git a/docs/PLAN.md b/docs/PLAN.md index 49fb768..f37f441 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);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3);主题下拉选中即预览、取消恢复(§5.6);空白新卡片确认不创建(§5.4);app.json 脏标记按"改动过"判定(改回原值仍写盘)(§4.3) +- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`、默认文件名剔除非法字符(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3);主题下拉选中即预览、取消恢复(§5.6);空白新卡片确认不创建(§5.4);app.json 脏标记按"改动过"判定(改回原值仍写盘),字段类型不符不做隐式转换(§4.3) - 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9) ## 1. 项目概述 @@ -141,7 +141,7 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE - 读取: - 文件缺失 → 默认值启动照常运行(下次脏退出时自然创建) - JSON 本身无法解析 → 备份为 `app.json.bak`(覆盖旧备份)→ 以默认值启动 - - JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等)→ **仅该字段回退默认值**,其余字段保留,不生成备份 + - JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等;类型须严格匹配,不做隐式转换——如布尔字段写 `0` / `"false"`、整数字段写 `"500"` / `500.0` 均视为非法)→ **仅该字段回退默认值**,其余字段保留,不生成备份 - 文件存在但无法读取(IO / 权限错误)→ 以默认值启动;`.bak` 备份写不出时跳过备份,照常以默认值启动 - 退出写盘失败(IO / 权限错误)→ 忽略,等同丢失当次会话的设置变更;无论写盘成败都释放工作区锁与连接