From f96d04c65a8037d57c2807a7cae277bfb498560e Mon Sep 17 00:00:00 2001 From: zhaoxiangyu Date: Tue, 20 Oct 2026 19:55:23 -0700 Subject: [PATCH] fix: recover unreadable settings without losing original data --- CLAUDE.md | 2 +- docs/CLAUDE.md | 2 +- docs/issues.md | 3 + src-tauri/src/CLAUDE.md | 7 +- src-tauri/src/lib.rs | 21 ++- src-tauri/src/settings_db.rs | 161 ++++++++++++++++++++- src-tauri/src/store.rs | 194 ++++++-------------------- src-tauri/src/store_tests.rs | 262 +++++++++++++++++++++++++++++++++++ src/components/CLAUDE.md | 2 +- src/components/Layout.jsx | 52 ++++++- 10 files changed, 535 insertions(+), 171 deletions(-) create mode 100644 src-tauri/src/store_tests.rs diff --git a/CLAUDE.md b/CLAUDE.md index b458290..a852994 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,5 +17,5 @@ rust-toolchain.toml - 固定 Rust 编译器和质量检查工具 README.md / docs/README.en.md - 中文默认与英文独立产品入口,简明游戏使用与开发导航互相对应 src-tauri/tauri.conf.json - 窗口、安全策略、安装器和更新签名 -边界: Rust 是 SQLite 设置、系统凭据与模型请求的唯一权威;主窗口模型编辑可查看自己的 Key,秘密不进入共享设置或数据库;模型预设/URL 分类(仅回环与私有网段可用 http)、外观主题、剪贴板、外链、资源路径及更新均由 Rust 执行,前端只渲染与编辑草稿。macOS 键盘创建、布局查询与短注入只在主线程,网络、等待与剪贴板留在后台;原位核验占位与焦点再替换/恢复,浮窗确认回填、不自动发送。录屏只通过单文件 asset scope 按需读取,视觉由用户验收;release 全部平台验证后才发布。 +边界: Rust 是 SQLite 设置、系统凭据与模型请求的唯一权威;主窗口模型编辑可查看自己的 Key,秘密不进入共享设置或数据库;模型预设/URL 分类(仅回环与私有网段可用 http)、外观主题、剪贴板、外链、资源路径及更新均由 Rust 执行,前端只渲染与编辑草稿;无法解析的设置仅由主窗口在用户确认后完整备份并恢复默认,系统凭据保留。macOS 键盘创建、布局查询与短注入只在主线程,网络、等待与剪贴板留在后台;原位核验占位与焦点再替换/恢复,浮窗确认回填、不自动发送。录屏只通过单文件 asset scope 按需读取,视觉由用户验收;release 全部平台验证后才发布。 法则: 极简·稳定·导航·版本精确 diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 8923919..4fdc92f 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -2,5 +2,5 @@ > L2 | 父级: ../CLAUDE.md 成员清单 README.en.md: 根 README 的独立英文简明入口,以原版演示插图、平台下载表与游戏操作步骤引导使用;详细实现交给 issues.md,保存与输入边界保持中英文等价。 -issues.md: 调查覆盖与发布后处理记录,将逐条取舍、公开产物证据和现场验收边界连接到实现。 +issues.md: 调查覆盖与发布后处理记录,将逐条取舍、公开产物证据和现场验收边界连接到实现;#65 保持开放直至受影响配置验收。 [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md diff --git a/docs/issues.md b/docs/issues.md index c60140b..6b4d7cf 100644 --- a/docs/issues.md +++ b/docs/issues.md @@ -62,6 +62,7 @@ Rust 统一持有 SQLite 设置、系统凭据和模型请求。普通输入框 | [#62](https://github.com/liseami/DeepRant/issues/62) | Rust 明确区分空响应 / 非 JSON / 无最终文本,不再直接调用前端 Response.json。 | 响应解析有原生单测。 | | [#63](https://github.com/liseami/DeepRant/issues/63) | 物理 code 录制,小键盘数字 / F 键单键,释放修饰键后执行,应用内冲突回滚。 | NumLock 必须开启;Windows 无法区分 NumpadEnter / Equal,明确拒绝。 | | [#64](https://github.com/liseami/DeepRant/issues/64) | 2.0.1 为星际公民的自动游戏输入默认选择原生浮窗,不依赖聊天框复制;保留船名 / 航点语境,确认后核验原窗口与焦点再回填,消息由用户发送。 | 12 组路由回归覆盖星际公民 / LoL、日常及显式模式覆盖;游戏现场与 EAC 兼容由用户验收,不声称已获白名单或认证。 | +| [#65](https://github.com/liseami/DeepRant/issues/65) | 截图对应设置反序列化失败,所有页面共用设置入口因而同时阻塞。加载时移除只供界面渲染的旧模型状态;真正坏字段仍保留原文,并提供用户确认后的完整备份与默认值恢复,系统凭据不清除。 | 已重新打开。正常 1.1.7 配置本身可迁移,尚未取得报告者原始配置,不能断言具体坏字段;修复代码与回归不等于受影响 Windows 配置已验收,不自动关闭。 | | [#66](https://github.com/liseami/DeepRant/issues/66) | 2.0.2 侧栏新增系统 / 浅色 / 深色外观,默认跟随系统;偏好存 SQLite,Rust 启动时只读数据库并下发应用级原生主题,标题栏、主窗口与浮窗 WebView 同步,前端只跟随 prefers-color-scheme 拨动 html.dark。 | 设置校验与 patch 有原生单测;深色视觉由用户验收。 | | [#67](https://github.com/liseami/DeepRant/issues/67) | 2.0.2 Base URL 对回环与私有网段(RFC 1918、链路本地、CGNAT 100.64/10、IPv6 ULA / 链路本地、`*.local`)接受 http,Key 可选并显示明文提示;公网仍只接受 https 且必须有 Key,不跟随重定向。 | 17 组地址分类回归;普通域名不经 DNS 解析判断,指向内网的公网域名仍需 https;局域网中转现场由用户验收。 | @@ -90,6 +91,8 @@ Rust 统一持有 SQLite 设置、系统凭据和模型请求。普通输入框 2026-10-19 追加 #66 / #67:[2.0.2](https://github.com/liseami/DeepRant/releases/tag/v2.0.2) 已通过 [Actions 39366145453](https://github.com/liseami/DeepRant/actions/runs/39366145453) 发布,标签固定受测提交 `23ed5d019172cf2a126055366f87605a99886a04`。三平台构建、Windows 兼容验收与发布五个 job 全部成功,首次推送到公开共 17 分 14 秒。本机先通过 fmt、25 项原生单测、clippy、7 项契约测试与发布脚本测试;发布后下载 10 个公开资产,SHA-256 全部一致,`latest.json` 为 2.0.2 且三平台更新签名与 `.sig` 一致,3 个安装包均有本仓库发布工作流的来源证明。[#66](https://github.com/liseami/DeepRant/issues/66) 与 [#67](https://github.com/liseami/DeepRant/issues/67) 已写明操作步骤与 http 明文边界后按 completed 关闭;深色视觉与局域网中转现场由用户验收。 +2026-10-20 追加 #65:已重新打开,避免把没有诊断的关闭状态当成修复证据。截图精确命中设置反序列化错误,但没有报告者原始配置,不能确定具体字段。代码补齐旧模型运行时属性的兼容解码;其余格式错误由主窗口确认后先将固定 SQLite 文件与旧 JSON 完整复制到独立备份目录,再用 SQLite 事务写入默认值。备份失败不写入、写入失败回滚,Unix 备份目录权限为 700;旧 JSON 原文保留,恢复后的数据库关闭旧文件重新导入与清理待办,系统凭据不删除。新增 6 项回归,本机 31 项原生测试、7 项契约检查、前端构建、6 项发布脚本测试、fmt 与 clippy 通过;真实发布签名包验证仍按现有规则忽略,本轮没有发布新版本。Windows 与 macOS CI 及受影响配置的现场结果另行补充。 + 供应商资料:[DeepSeek](https://api-docs.deepseek.com/)、[OpenAI GPT-6 Luna](https://developers.openai.com/api/docs/models/gpt-6-luna)、[Ollama Qwen3.5 4B](https://ollama.com/library/qwen3.5:4b)、[腾讯 TokenHub](https://cloud.tencent.com/document/product/1823/130079)、[腾讯旧 R1 下线](https://cloud.tencent.com/announce/detail/2258)、[硅基](https://api-docs.siliconflow.cn/docs/api/chat-completions-post)、[302.AI](https://doc.302.ai/171145944e0)、[R1 历史兼容建议](https://github.com/deepseek-ai/DeepSeek-R1#usage-recommendations)。 [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md diff --git a/src-tauri/src/CLAUDE.md b/src-tauri/src/CLAUDE.md index fbd02a4..505dc15 100644 --- a/src-tauri/src/CLAUDE.md +++ b/src-tauri/src/CLAUDE.md @@ -1,13 +1,14 @@ # src-tauri/src/ > L2 | 父级: ../CLAUDE.md 成员清单 -lib.rs: 生命周期与原生业务/系统 IPC 登记,管理更新状态;setup 先套用已存外观,Ready 先显示再初始化,主窗口隐藏/浮窗取消,警告日志不记录原文。 +lib.rs: 生命周期与原生业务/系统 IPC 登记,管理更新状态;仅主窗口可恢复损坏设置,成功后重建快捷键;Ready 先显示再初始化,警告日志不记录原文。 desktop_services.rs: Rust 桌面服务边界,按窗口身份限制文本复制、固定外链 ID 和单录屏 URL;应用级主题驱动标题栏与全部 WebView 深浅色;前端不获得通用系统或路径插件能力,asset 仍按 Range 读取原资源。 input_diagnostics.rs: 实时权限与真实注册状态、最近输入的只内存快照;固定系统设置入口不代替用户授权,热键和复制阶段不进磁盘日志。 main.rs: 桌面程序入口,只调用库运行函数。 credentials.rs: 系统钥匙串凭据唯一边界,以协议及端点隔离 Key;仅主窗口模型编辑可按服务查看,不进入公开设置或诊断。 -store.rs: 设置校验与升级迁移;所有设置经单一锁写入 SQLite,凭据先迁移验证、数据库提交后才清除旧 JSON 明文;启动外观只读 SQLite,不等待钥匙串。 -settings_db.rs: 单行 JSON 文档的 SQLite 事务,保留未知根字段;首次读取旧文件,之后数据库唯一权威,待办标记让明文清理可重试。 +store.rs: 设置校验与升级迁移;单锁写入 SQLite,加载时剥除旧模型运行时状态;格式错误仅经显式备份恢复,系统凭据保留;启动外观不等待钥匙串。 +store_tests.rs: 设置迁移与写入边界回归,覆盖 1.1.7、旧运行时属性、凭据分离、损坏字段脱敏及合法设置往返。 +settings_db.rs: 单行 JSON 文档的 SQLite 事务,保留未知根字段;首次读旧文件,之后数据库唯一权威;恢复前完整备份固定设置文件,备份失败不写默认值。 ai_translator.rs: 模型请求与最终文本解析;公网强制 Key,本机 / 局域网 Key 可选;直接消费可查看的同源提示词,现代 OpenAI 使用 developer/完成预算,Qwen 默认关闭思考,旧模型兼容不污染其他协议。 translation_prompt.rs: Rust 完整生效提示词唯一生成源;嘴臭创造性改写、自定义要求优先;主窗口查看/复制只读取已保存设置,不访问模型 Key。 model_presets.rs: Rust 唯一供应商目录,向界面提供当前预设、官方端点与精确 ID;历史标签仅兼容,迁移不重置用户地址。 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 759be2b..7258069 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,6 @@ /** * [INPUT]: store 设置与凭据迁移、shortcut 注册事务、模型与游戏输入管道、input_diagnostics 的权限和阶段快照。 - * [OUTPUT]: 设置/翻译/输入诊断 IPC 与仅主窗口的 Key 查看;启动先套用已存外观,Ready 先激活主窗口,再异步初始化凭据与快捷键。 + * [OUTPUT]: 设置/翻译/输入诊断 IPC 与仅主窗口的 Key 查看、损坏设置备份恢复;Ready 先显示主窗口,再异步初始化凭据与快捷键。 * [POS]: 仅编排边界模块;主线程不等待 Keychain,输入失败显示主窗口,浮窗关闭取消;警告日志不记录聊天原文。 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ @@ -41,6 +41,24 @@ async fn get_settings(app_handle: tauri::AppHandle) -> Result Result { + if window.label() != "main" { + return Err("只有主窗口可以恢复默认设置".into()); + } + let app = window.app_handle().clone(); + tauri::async_runtime::spawn_blocking(move || { + let backup = store::restore_default_settings(&app).map_err(|e| e.to_string())?; + desktop_services::apply_appearance(&app, "system"); + if let Err(error) = shortcut::init_shortcuts(&app) { + shell_helper::report_status(&app, "error", &format!("快捷键注册失败:{error}")); + } + Ok(backup.to_string_lossy().into_owned()) + }) + .await + .map_err(|_| "设置恢复任务被中断,请重试读取设置".to_string())? +} + #[tauri::command] async fn update_settings( app_handle: tauri::AppHandle, @@ -304,6 +322,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_version, get_settings, + restore_default_settings, update_settings, clear_api_key, read_api_key, diff --git a/src-tauri/src/settings_db.rs b/src-tauri/src/settings_db.rs index 5cec126..efd57b3 100644 --- a/src-tauri/src/settings_db.rs +++ b/src-tauri/src/settings_db.rs @@ -1,7 +1,7 @@ /** * [INPUT]: 依赖 rusqlite 的事务、serde_json 的设置文档与 tempfile 的旧文件原子替换 - * [OUTPUT]: 提供设置快照、事务提交与旧 JSON 清理;数据库不存在记录时才读取迁移来源 - * [POS]: store 的磁盘边界;单行文档保留未知根字段,提交后才清理旧明文,错误不覆盖可恢复数据 + * [OUTPUT]: 提供设置快照、事务提交、旧 JSON 清理与先完整备份再恢复默认设置的入口 + * [POS]: store 的磁盘边界;数据库不存在记录时才读取旧文件,显式恢复保留全部原文且不触碰凭据 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ use anyhow::{anyhow, bail, Result}; @@ -9,8 +9,8 @@ use rusqlite::{Connection, OptionalExtension}; use serde_json::{json, Value}; use std::fs; use std::io::{ErrorKind, Write}; -use std::path::Path; -use std::time::Duration; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tempfile::NamedTempFile; const DATABASE_FILENAME: &str = "settings.sqlite3"; @@ -119,6 +119,65 @@ pub fn write(directory: &Path, root: &Value, pending: bool) -> Result<()> { write_document(&mut connect(directory)?, root, pending) } +/// 用户确认后才调用:完整备份成功之前不打开数据库,旧 JSON 不参与恢复后的读取。 +pub fn restore_defaults(directory: &Path, root: &Value) -> Result { + validate_document(root)?; + fs::create_dir_all(directory).map_err(|_| anyhow!("无法创建设置目录,原数据已保留"))?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| anyhow!("系统时间无效,无法创建设置备份;原数据已保留"))? + .as_nanos(); + let backup_directory = directory.join(format!("settings-backup-{timestamp}")); + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder + .recursive(false) + .create(&backup_directory) + .map_err(|_| anyhow!("无法创建独立设置备份目录,原数据已保留"))?; + let backup = || -> std::io::Result<()> { + for filename in [ + DATABASE_FILENAME, + "settings.sqlite3-wal", + "settings.sqlite3-shm", + "settings.sqlite3-journal", + LEGACY_FILENAME, + ] { + let mut original = match fs::File::open(directory.join(filename)) { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; + if !original.metadata()?.is_file() { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + "设置备份来源不是文件", + )); + } + let mut copied = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(backup_directory.join(filename))?; + std::io::copy(&mut original, &mut copied)?; + copied.sync_all()?; + } + #[cfg(unix)] + { + fs::File::open(&backup_directory)?.sync_all()?; + fs::File::open(directory)?.sync_all()?; + } + Ok(()) + }; + backup().map_err(|_| anyhow!("设置备份未完成,原数据已保留;请检查磁盘空间与目录权限"))?; + // SQLite 事务失败会回滚;旧 JSON 原文保留,不在未验证旧 Key 的情况下删除凭据来源。 + write(directory, root, false) + .map_err(|error| anyhow!("{error};恢复前备份位于 {}", backup_directory.display()))?; + Ok(backup_directory) +} + pub fn read_legacy(directory: &Path) -> Result> { let bytes = match fs::read(directory.join(LEGACY_FILENAME)) { Ok(bytes) => bytes, @@ -284,4 +343,98 @@ mod tests { assert_eq!(read_legacy(directory.path()).unwrap(), Some(sanitized)); assert_eq!(read(directory.path()).unwrap().root, document); } + + #[test] + fn explicit_restore_backs_up_invalid_settings_and_preserves_legacy_original() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path(); + write(path, &json!({"settings": {"daily_mode": "invalid"}}), true).unwrap(); + let database = fs::read(path.join(DATABASE_FILENAME)).unwrap(); + let legacy = b"{invalid-json-with-original-settings"; + fs::write(path.join(LEGACY_FILENAME), legacy).unwrap(); + let defaults = json!({"settings": {"daily_mode": false}}); + + let backup = restore_defaults(path, &defaults).unwrap(); + + assert_eq!(fs::read(backup.join(DATABASE_FILENAME)).unwrap(), database); + assert_eq!(fs::read(backup.join(LEGACY_FILENAME)).unwrap(), legacy); + assert_eq!(fs::read(path.join(LEGACY_FILENAME)).unwrap(), legacy); + let restored = read(path).unwrap(); + assert_eq!(restored.root, defaults); + assert!(!restored.from_legacy && !restored.legacy_cleanup_pending); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(backup).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + } + + #[test] + fn failed_restore_backup_or_transaction_keeps_source_files_unchanged() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path(); + let original = json!({"settings": {"daily_mode": true}}); + let defaults = json!({"settings": {"daily_mode": false}}); + write(path, &original, true).unwrap(); + let legacy = b"{invalid-legacy"; + fs::write(path.join(LEGACY_FILENAME), legacy).unwrap(); + let database = fs::read(path.join(DATABASE_FILENAME)).unwrap(); + let invalid_sidecar = path.join("settings.sqlite3-wal"); + fs::create_dir(&invalid_sidecar).unwrap(); + + assert!(restore_defaults(path, &defaults).is_err()); + assert_eq!(fs::read(path.join(DATABASE_FILENAME)).unwrap(), database); + assert_eq!(fs::read(path.join(LEGACY_FILENAME)).unwrap(), legacy); + fs::remove_dir(invalid_sidecar).unwrap(); + + connect(path) + .unwrap() + .execute_batch( + "CREATE TRIGGER reject_restore BEFORE UPDATE ON settings_document + BEGIN SELECT RAISE(ABORT, 'test rollback'); END;", + ) + .unwrap(); + let database = fs::read(path.join(DATABASE_FILENAME)).unwrap(); + assert!(restore_defaults(path, &defaults).is_err()); + assert_eq!(fs::read(path.join(DATABASE_FILENAME)).unwrap(), database); + assert_eq!(fs::read(path.join(LEGACY_FILENAME)).unwrap(), legacy); + let retained = read(path).unwrap(); + assert_eq!(retained.root, original); + assert!(retained.legacy_cleanup_pending); + } + + #[test] + fn repeated_restore_keeps_each_backup_and_rejects_invalid_defaults_before_writes() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path(); + write(path, &json!({"settings": {"daily_mode": true}}), false).unwrap(); + let defaults = json!({"settings": {"daily_mode": false}}); + let first = restore_defaults(path, &defaults).unwrap(); + let first_database = fs::read(first.join(DATABASE_FILENAME)).unwrap(); + write(path, &json!({"settings": {"translation_to": "ja"}}), false).unwrap(); + let second_database = fs::read(path.join(DATABASE_FILENAME)).unwrap(); + let second = restore_defaults(path, &defaults).unwrap(); + + assert_ne!(first, second); + assert_eq!( + fs::read(first.join(DATABASE_FILENAME)).unwrap(), + first_database + ); + assert_eq!( + fs::read(second.join(DATABASE_FILENAME)).unwrap(), + second_database + ); + let entries = fs::read_dir(path).unwrap().count(); + assert!(restore_defaults(path, &json!({"settings": null})).is_err()); + assert!(restore_defaults( + path, + &json!({"settings": {"custom_model": {"auth": "must-not-persist"}}}) + ) + .is_err()); + assert_eq!(fs::read_dir(path).unwrap().count(), entries); + assert_eq!(read(path).unwrap().root, defaults); + } } diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 31d5a05..be9e3bb 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -1,7 +1,7 @@ /** * [INPUT]: 依赖 settings_db 的 SQLite 事务与旧 JSON 来源、credentials 的系统凭据 - * [OUTPUT]: 提供 AppSettings、模型配置、校验、设置 patch、快捷键事务持久化与不触碰凭据的启动外观读取 - * [POS]: 设置的唯一写边界;全部配置经校验后入库,迁移验证凭据并提交数据库后才清旧明文 + * [OUTPUT]: 提供 AppSettings、兼容解码、设置 patch、快捷键事务持久化、损坏设置备份恢复与启动外观读取 + * [POS]: 设置的唯一写边界;全部配置经校验后入库,迁移验证凭据后才清旧明文;格式错误只能经显式恢复并保留原数据 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ use anyhow::{anyhow, bail, Result}; @@ -327,24 +327,47 @@ fn refresh_key_state(settings: &mut AppSettings) -> Result<()> { Ok(()) } -fn load_unlocked(app: &AppHandle) -> Result { - let directory = settings_directory(app)?; - let snapshot = crate::settings_db::read(&directory)?; - let mut root = canonical_root(snapshot.root); - let existing = root.get("settings").cloned(); - let mut raw = existing.clone().unwrap_or_else(|| json!({})); - let legacy_key = raw - .get_mut("custom_model") - .and_then(Value::as_object_mut) - .and_then(|model| model.remove("auth")); +fn decode_settings(mut raw: Value) -> Result<(AppSettings, Option)> { + let model = raw.get_mut("custom_model").and_then(Value::as_object_mut); + let legacy_key = model.and_then(|model| { + // 旧缓存可能带有只供界面显示的状态;读取时丢弃,随后由系统重新计算。 + for field in ["has_api_key", "is_local", "official_deepseek"] { + model.remove(field); + } + model.remove("auth") + }); if legacy_key .as_ref() .is_some_and(|key| !key.is_string() && !key.is_null()) { bail!("旧 API Key 格式无效,原设置已保留,请先备份 store.json 后修复凭据字段"); } - let mut settings: AppSettings = serde_json::from_value(raw) - .map_err(|_| anyhow!("设置格式无效,原数据已保留;请备份应用数据目录后修复"))?; + let settings = serde_json::from_value(raw) + .map_err(|_| anyhow!("设置格式无效,原数据已保留;可备份并恢复默认设置"))?; + Ok((settings, legacy_key)) +} + +/// 仅处理无法解析的设置;原数据完整备份后才写默认值,不读取或删除系统凭据。 +pub fn restore_default_settings(app: &AppHandle) -> Result { + let _guard = SETTINGS_LOCK + .lock() + .map_err(|_| anyhow!("设置操作被中断,请重启应用"))?; + let directory = settings_directory(app)?; + let mut root = canonical_root(crate::settings_db::read(&directory)?.root); + if decode_settings(root["settings"].clone()).is_ok() { + bail!("当前设置格式正常,无需恢复默认设置;请按原错误提示处理"); + } + root["settings"] = serialized_settings(&AppSettings::default()); + crate::settings_db::restore_defaults(&directory, &root) +} + +fn load_unlocked(app: &AppHandle) -> Result { + let directory = settings_directory(app)?; + let snapshot = crate::settings_db::read(&directory)?; + let mut root = canonical_root(snapshot.root); + let existing = root.get("settings").cloned(); + let (mut settings, legacy_key) = + decode_settings(existing.clone().unwrap_or_else(|| json!({})))?; settings.custom_model.api_url = normalize_api_url( &settings.custom_model.api_url, &settings.custom_model.protocol, @@ -626,144 +649,5 @@ pub fn set_phrases_enabled(app: &AppHandle, enabled: bool) -> Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn legacy_root_layout_keeps_unknown_metadata_and_every_setting() { - let mut configured = AppSettings { - trans_hotkey: HotkeyConfig::new_platform_specific("NumpadEnter"), - phrases_enabled: true, - ui_language: "en".into(), - appearance: "dark".into(), - custom_instructions: "保持原意".into(), - ..AppSettings::default() - }; - configured.phrases[0].phrase = "保留常用语和快捷键".into(); - configured.phrases[0].hotkey = HotkeyConfig::new_platform_specific("F7"); - configured.phrases[0].enabled = false; - let settings = serialized_settings(&configured); - let mut direct = settings.clone(); - direct["other"] = json!({"retained": true}); - let canonical = canonical_root(direct); - assert_eq!(canonical["settings"], settings); - assert_eq!(canonical["other"], json!({"retained": true})); - assert_eq!(canonical_root(canonical.clone()), canonical); - let directory = tempfile::tempdir().unwrap(); - crate::settings_db::write(directory.path(), &canonical, false).unwrap(); - let persisted = crate::settings_db::read(directory.path()).unwrap().root; - assert_eq!(persisted, canonical); - let restored: AppSettings = serde_json::from_value(persisted["settings"].clone()).unwrap(); - assert_eq!(serialized_settings(&restored), settings); - } - - #[test] - fn patch_rejects_invalid_fields_and_never_serializes_credentials() { - let current = AppSettings::default(); - assert!(apply_patch(¤t, json!({"trans_hotkey": {}})).is_err()); - assert!(apply_patch(¤t, json!({"unknown": true})).is_err()); - assert!(apply_patch(¤t, json!({"daily_mode": "yes"})).is_err()); - let (next, secret) = - apply_patch(¤t, json!({"custom_model": {"auth": "test-secret"}})).unwrap(); - assert_eq!(secret.as_deref(), Some("test-secret")); - assert!(!serialized_settings(&next) - .to_string() - .contains("test-secret")); - assert!( - apply_patch(¤t, json!({"custom_model": {"auth": " "}})) - .unwrap() - .1 - .is_none() - ); - let defaults: AppSettings = serde_json::from_value(json!({"daily_mode": true})).unwrap(); - assert!(defaults.daily_mode); - assert_eq!(defaults.input_mode, "auto"); - assert!(apply_patch(¤t, json!({"input_mode": "hook"})).is_err()); - assert_eq!(defaults.appearance, "system"); - assert!(apply_patch(¤t, json!({"appearance": "sepia"})).is_err()); - assert_eq!( - apply_patch(¤t, json!({"appearance": "dark"})) - .unwrap() - .0 - .appearance, - "dark" - ); - assert_eq!( - apply_patch(¤t, json!({"input_mode": "compose"})) - .unwrap() - .0 - .input_mode, - "compose" - ); - assert_eq!(defaults.custom_model.model_name, "deepseek-flash"); - } - - #[test] - fn endpoints_are_idempotent_and_disallow_embedded_credentials() { - for (input, protocol, expected) in [ - ( - "https://api.deepseek.com", - "openai", - "https://api.deepseek.com/chat/completions", - ), - ( - "https://example.com/v1/", - "openai", - "https://example.com/v1/chat/completions", - ), - ( - "http://localhost:11434", - "openai", - "http://localhost:11434/v1/chat/completions", - ), - ( - "https://api.anthropic.com", - "anthropic", - "https://api.anthropic.com/v1/messages", - ), - ] { - assert_eq!(normalize_api_url(input, protocol).unwrap(), expected); - assert_eq!(normalize_api_url(expected, protocol).unwrap(), expected); - } - assert!(normalize_api_url("https://key@example.com", "openai").is_err()); - assert!(normalize_api_url("https://example.com?key=secret", "openai").is_err()); - assert!(normalize_api_url("http://example.com", "openai").is_err()); - assert!(normalize_api_url("http://[::1]:11434", "openai").is_ok()); - } - - #[test] - fn private_network_endpoints_accept_http_but_public_hosts_require_https() { - for host in [ - "192.168.1.20:3000", - "10.0.0.8", - "172.31.255.1", - "169.254.10.2", - "100.101.102.103", - "[fd12:3456::1]", - "[fe80::1]", - "relay.local:8080", - ] { - let endpoint = normalize_api_url(&format!("http://{host}"), "openai").unwrap(); - assert_eq!(endpoint, format!("http://{host}/v1/chat/completions")); - let url = reqwest::Url::parse(&endpoint).unwrap(); - assert!(key_optional(&url) && !is_loopback(&url), "{host}"); - } - for host in [ - "8.8.8.8", - "172.32.0.1", - "100.128.0.1", - "192.169.0.1", - "[2001:db8::1]", - "local", - "relay.local.example.com", - "nas", - ] { - assert!( - normalize_api_url(&format!("http://{host}"), "openai").is_err(), - "{host}" - ); - } - let public = reqwest::Url::parse("https://192.168.1.20.nip.io/v1").unwrap(); - assert!(!key_optional(&public)); - } -} +#[path = "store_tests.rs"] +mod tests; diff --git a/src-tauri/src/store_tests.rs b/src-tauri/src/store_tests.rs new file mode 100644 index 0000000..ffb08fe --- /dev/null +++ b/src-tauri/src/store_tests.rs @@ -0,0 +1,262 @@ +/** + * [INPUT]: 依赖 store 的设置解码、补丁校验、持久化形状与端点规范,以及 settings_db 的临时目录事务 + * [OUTPUT]: 提供旧设置迁移、严格校验及数据库往返的回归测试 + * [POS]: store 的单元测试边界;隔离磁盘与解析验证,不访问系统凭据或桌面运行时 + * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md + */ +use super::*; + +#[test] +fn legacy_runtime_fields_are_discarded_without_losing_saved_configuration() { + let configured = AppSettings { + trans_hotkey: HotkeyConfig::new_platform_specific("F8"), + translation_from: "ja".into(), + translation_to: "zh".into(), + model_type: "custom".into(), + custom_model: ModelConfig { + api_url: "https://example.com/v1/chat/completions".into(), + model_name: "retained-model".into(), + reasoning_model: true, + ..ModelConfig::default() + }, + phrases: vec![Phrase { + id: 42, + phrase: "保留用户常用语".into(), + hotkey: HotkeyConfig::new_platform_specific("F7"), + enabled: false, + }], + phrases_enabled: true, + ..AppSettings::default() + }; + let expected = serialized_settings(&configured); + let mut legacy = expected.clone(); + legacy["custom_model"]["has_api_key"] = json!(true); + legacy["custom_model"]["is_local"] = json!(true); + legacy["custom_model"]["official_deepseek"] = json!(false); + legacy["custom_model"]["auth"] = json!("legacy-migration-secret"); + + let (restored, key) = decode_settings(legacy).unwrap(); + assert_eq!(key, Some(json!("legacy-migration-secret"))); + assert!(!restored.custom_model.has_api_key); + assert!(!restored.custom_model.is_local); + assert_eq!( + restored.custom_model.official_deepseek, + ModelConfig::default().official_deepseek + ); + let persisted = serialized_settings(&restored); + assert_eq!(persisted, expected); + for field in ["auth", "has_api_key", "is_local", "official_deepseek"] { + assert!(persisted["custom_model"].get(field).is_none(), "{field}"); + } +} + +#[test] +fn version_1_1_7_settings_keep_user_values_and_fill_new_defaults() { + let hotkey = json!({"modifiers": ["Alt"], "key": "KeyQ", "shortcut": "Alt+Q"}); + let legacy = json!({ + "trans_hotkey": hotkey, + "translation_from": "zh", + "translation_to": "en", + "game_scene": "dota2", + "translation_mode": "toxic", + "daily_mode": true, + "model_type": "custom", + "custom_model": { + "auth": "version-1-1-7-secret", + "api_url": "https://example.com/v1/chat/completions", + "model_name": "user-model" + }, + "phrases": [{"id": 7, "phrase": "保留旧版短语", "hotkey": hotkey}], + "phrases_enabled": true + }); + + let (restored, key) = decode_settings(legacy.clone()).unwrap(); + assert_eq!(key, Some(json!("version-1-1-7-secret"))); + let persisted = serialized_settings(&restored); + assert_eq!(persisted["trans_hotkey"], legacy["trans_hotkey"]); + assert_eq!( + persisted["phrases"][0]["hotkey"], + legacy["phrases"][0]["hotkey"] + ); + assert_eq!(restored.phrases[0].phrase, "保留旧版短语"); + assert!(restored.phrases[0].enabled && restored.phrases_enabled && restored.daily_mode); + assert_eq!( + restored.custom_model.api_url, + legacy["custom_model"]["api_url"].as_str().unwrap() + ); + assert_eq!(restored.custom_model.model_name, "user-model"); + assert_eq!(restored.custom_model.protocol, "openai"); + assert_eq!(restored.input_mode, "auto"); + assert_eq!(restored.ui_language, "zh"); + assert_eq!(restored.appearance, "system"); + assert!(persisted["custom_model"].get("auth").is_none()); + assert!(!persisted.to_string().contains("version-1-1-7-secret")); + assert_eq!(decode_settings(json!({})).unwrap().1, None); + assert_eq!( + decode_settings(json!({"custom_model": {"auth": null}})) + .unwrap() + .1, + Some(Value::Null) + ); +} + +#[test] +fn settings_decode_rejects_corrupt_fields_without_exposing_values() { + let private_value = "private-value-must-never-appear-in-errors"; + for raw in [ + json!({"unknown": private_value}), + json!({"custom_model": {"unknown": private_value}}), + json!({"custom_model": null}), + json!({"trans_hotkey": null}), + json!({"phrases": null}), + json!({"phrases": [{"id": 1, "phrase": private_value, "hotkey": null}]}), + json!({"daily_mode": private_value}), + json!({"custom_model": {"model_name": [private_value]}}), + json!({"custom_model": {"auth": true}}), + json!({"custom_model": {"auth": 782347928}}), + json!({"custom_model": {"auth": [private_value]}}), + json!({"custom_model": {"auth": {"value": private_value}}}), + ] { + let error = decode_settings(raw).unwrap_err().to_string(); + assert!(!error.contains(private_value)); + assert!(!error.contains("782347928")); + } +} + +#[test] +fn legacy_root_layout_keeps_unknown_metadata_and_every_setting() { + let mut configured = AppSettings { + trans_hotkey: HotkeyConfig::new_platform_specific("NumpadEnter"), + phrases_enabled: true, + ui_language: "en".into(), + appearance: "dark".into(), + custom_instructions: "保持原意".into(), + ..AppSettings::default() + }; + configured.phrases[0].phrase = "保留常用语和快捷键".into(); + configured.phrases[0].hotkey = HotkeyConfig::new_platform_specific("F7"); + configured.phrases[0].enabled = false; + let settings = serialized_settings(&configured); + let mut direct = settings.clone(); + direct["other"] = json!({"retained": true}); + let canonical = canonical_root(direct); + assert_eq!(canonical["settings"], settings); + assert_eq!(canonical["other"], json!({"retained": true})); + assert_eq!(canonical_root(canonical.clone()), canonical); + let directory = tempfile::tempdir().unwrap(); + crate::settings_db::write(directory.path(), &canonical, false).unwrap(); + let persisted = crate::settings_db::read(directory.path()).unwrap().root; + assert_eq!(persisted, canonical); + let restored: AppSettings = serde_json::from_value(persisted["settings"].clone()).unwrap(); + assert_eq!(serialized_settings(&restored), settings); +} + +#[test] +fn patch_rejects_invalid_fields_and_never_serializes_credentials() { + let current = AppSettings::default(); + assert!(apply_patch(¤t, json!({"trans_hotkey": {}})).is_err()); + assert!(apply_patch(¤t, json!({"unknown": true})).is_err()); + assert!(apply_patch(¤t, json!({"daily_mode": "yes"})).is_err()); + let (next, secret) = + apply_patch(¤t, json!({"custom_model": {"auth": "test-secret"}})).unwrap(); + assert_eq!(secret.as_deref(), Some("test-secret")); + assert!(!serialized_settings(&next) + .to_string() + .contains("test-secret")); + assert!( + apply_patch(¤t, json!({"custom_model": {"auth": " "}})) + .unwrap() + .1 + .is_none() + ); + let defaults: AppSettings = serde_json::from_value(json!({"daily_mode": true})).unwrap(); + assert!(defaults.daily_mode); + assert_eq!(defaults.input_mode, "auto"); + assert!(apply_patch(¤t, json!({"input_mode": "hook"})).is_err()); + assert_eq!(defaults.appearance, "system"); + assert!(apply_patch(¤t, json!({"appearance": "sepia"})).is_err()); + assert_eq!( + apply_patch(¤t, json!({"appearance": "dark"})) + .unwrap() + .0 + .appearance, + "dark" + ); + assert_eq!( + apply_patch(¤t, json!({"input_mode": "compose"})) + .unwrap() + .0 + .input_mode, + "compose" + ); + assert_eq!(defaults.custom_model.model_name, "deepseek-flash"); +} + +#[test] +fn endpoints_are_idempotent_and_disallow_embedded_credentials() { + for (input, protocol, expected) in [ + ( + "https://api.deepseek.com", + "openai", + "https://api.deepseek.com/chat/completions", + ), + ( + "https://example.com/v1/", + "openai", + "https://example.com/v1/chat/completions", + ), + ( + "http://localhost:11434", + "openai", + "http://localhost:11434/v1/chat/completions", + ), + ( + "https://api.anthropic.com", + "anthropic", + "https://api.anthropic.com/v1/messages", + ), + ] { + assert_eq!(normalize_api_url(input, protocol).unwrap(), expected); + assert_eq!(normalize_api_url(expected, protocol).unwrap(), expected); + } + assert!(normalize_api_url("https://key@example.com", "openai").is_err()); + assert!(normalize_api_url("https://example.com?key=secret", "openai").is_err()); + assert!(normalize_api_url("http://example.com", "openai").is_err()); + assert!(normalize_api_url("http://[::1]:11434", "openai").is_ok()); +} + +#[test] +fn private_network_endpoints_accept_http_but_public_hosts_require_https() { + for host in [ + "192.168.1.20:3000", + "10.0.0.8", + "172.31.255.1", + "169.254.10.2", + "100.101.102.103", + "[fd12:3456::1]", + "[fe80::1]", + "relay.local:8080", + ] { + let endpoint = normalize_api_url(&format!("http://{host}"), "openai").unwrap(); + assert_eq!(endpoint, format!("http://{host}/v1/chat/completions")); + let url = reqwest::Url::parse(&endpoint).unwrap(); + assert!(key_optional(&url) && !is_loopback(&url), "{host}"); + } + for host in [ + "8.8.8.8", + "172.32.0.1", + "100.128.0.1", + "192.169.0.1", + "[2001:db8::1]", + "local", + "relay.local.example.com", + "nas", + ] { + assert!( + normalize_api_url(&format!("http://{host}"), "openai").is_err(), + "{host}" + ); + } + let public = reqwest::Url::parse("https://192.168.1.20.nip.io/v1").unwrap(); + assert!(!key_optional(&public)); +} diff --git a/src/components/CLAUDE.md b/src/components/CLAUDE.md index 5cc4cc9..1157a5b 100644 --- a/src/components/CLAUDE.md +++ b/src/components/CLAUDE.md @@ -2,7 +2,7 @@ > L2 | 父级: ../CLAUDE.md 成员清单 StoreProvider.jsx: IPC 设置与模型目录视图,串行提交变更并接收 Rust 最终状态,不持有凭据。 -Layout.jsx: CCBaga 同款外框,先放全宽 Key 横幅,再排列 240px 侧栏与 Panel frame 工作面;常驻桌面通知与更新入口。 +Layout.jsx: CCBaga 同款外框、Key 横幅、侧栏和工作面;设置格式错误时提供确认后的备份恢复入口,成功显示备份位置;常驻桌面通知与更新入口。 ApiKeyBanner.jsx: 官方 DeepSeek 当前配置缺 Key 时直接输入保存,固定官方申请入口交给 Rust 打开;不重置游戏设置。 ApiKeyField.jsx: Key 真值只在字段局部状态,显隐、读取重试、晚到响应保护与服务隔离共用一个入口;原值对照避免重复写凭据,共享缓存只持有是否已保存。 Sidebar.jsx: Cromma NavButton 与 Pika 图标的五项游戏业务导航、界面语言与外观(系统/浅色/深色)入口,外观写入 Rust 设置;不设登录或额度入口。 diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index a9596c1..19620b6 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -1,20 +1,40 @@ /** - * [INPUT]: 依赖 Cromma UI Panel/Button、Key 首次配置横幅、设置读取状态及常驻桌面事件桥 - * [OUTPUT]: 提供全宽 Key 入口、CCBaga 同款侧栏、斜纹外框和单一可滚动工作面 - * [POS]: 游戏翻译外壳保留原导航和业务页面;设置与输入反馈继续由 Rust 契约驱动 + * [INPUT]: 依赖 Cromma UI、AppDialog、设置读取状态、Rust 备份恢复命令及常驻桌面事件桥 + * [OUTPUT]: 提供 Key 入口、侧栏和工作面;设置格式错误时经明确确认备份并恢复默认值 + * [POS]: 游戏翻译外壳;恢复操作只提交用户意图,原数据备份与默认值写入由 Rust 完成 * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md */ +import { useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; import { Toaster } from 'react-hot-toast'; import { Panel, Button } from 'cromma-ui'; import Sidebar from './Sidebar'; import BackgroundStatus from './BackgroundStatus'; import UpdateManager from './UpdateManager'; import ApiKeyBanner from './ApiKeyBanner'; +import AppDialog from './AppDialog'; import { useStore } from './StoreProvider'; +import { showError } from '../utils/toast'; export default function Layout({ children, activeItem, setActiveItem }) { - const { loading, error, refresh, t } = useStore(); + const { loading, error, refresh, t, locale } = useStore(); + const [confirmRecovery, setConfirmRecovery] = useState(false); + const [recovering, setRecovering] = useState(false); + const [backupDirectory, setBackupDirectory] = useState(''); + const english = locale === 'en'; + const recoverable = error.includes('设置格式无效') || error.includes('旧 API Key 格式无效'); + + async function recoverSettings() { + setRecovering(true); + try { + const backup = await invoke('restore_default_settings'); + setBackupDirectory(backup); + setConfirmRecovery(false); + await refresh(); + } catch (failure) { showError(failure); } + finally { setRecovering(false); } + } return (
@@ -25,14 +45,36 @@ export default function Layout({ children, activeItem, setActiveItem }) {
+ {backupDirectory &&

+ {english ? 'Settings restored. Your previous settings are backed up at: ' : '已恢复默认设置,旧设置备份位于:'}{backupDirectory} +

} {loading ?

{t('loading')}

: error ?

{error}

- +
+ + {recoverable && } +
: children}
+ { if (!recovering) setConfirmRecovery(open); }} + title={english ? 'Restore default settings?' : '恢复默认设置?'} + footer={<> + + + }> +

+ {english + ? 'Your current settings will be backed up before restoring defaults. You will need to select your model, shortcuts and phrases again. API keys in the system credential store will be kept. If the backup fails, your settings will stay unchanged.' + : '先完整备份现有设置,再恢复默认值。模型、快捷键和常用语需要重新配置;系统凭据库中的 API Key 保留。备份失败时不会修改原设置。'} +

+
); }