diff --git a/.claude/rules/tauri-backend.md b/.claude/rules/tauri-backend.md index 9725664..a0758d0 100644 --- a/.claude/rules/tauri-backend.md +++ b/.claude/rules/tauri-backend.md @@ -16,7 +16,7 @@ paths: | `main.rs` | 二进制入口,仅调用 `code_manager_lib::run()` | | `utils.rs` | 公共锁、JSON 读写、原子文件写入、应用数据目录解析 | | `config.rs` | 配置 / Provider 合并落盘、`resolve_profile_settings()`、模型测试、`config-registry.json` | -| `deep_link.rs` | 深度链接:`code-manager://profiles/import` 解析、内嵌 payload / 远端 HTTPS 拉取(SSRF 防护)、pending 队列与导入链接生成 | +| `deep_link.rs` | 深度链接:`code-manager://profiles/import` 解析、内嵌 payload / 远端 HTTPS 拉取(SSRF 防护)、pending 队列(peek/ack,切页不丢)与导入链接生成 | | `memory.rs` | 用户级 `CLAUDE.md` 与 `rules/*.md` 的托管、导入、启停 | | `skills.rs` | Skills 启停、`~/.codex/skills/` 软链同步、`SKILL.md` 读写、文件树扫描 | | `history.rs` | `~/.claude/history.jsonl` 读取、会话详情解析、轮询变更 | @@ -84,8 +84,9 @@ const workspace = await ipc.getConfigWorkspace(); ## 后端边界 - 后端继续负责配置合并、路径校验、目录遍历安全、真实落盘和日志脱敏。 -- 路径相关 command 必须防止符号链接、绝对路径和 `..` 路径逃逸。 -- `claude_directory` 和 `project` 的文件树/预览 command 只能暴露受控目录内部内容,不能让前端绕过后端路径边界。 +- 路径相关 command 必须防止绝对路径和 `..` 路径逃逸。 +- **只读跟随软链(ADR 0002)**:`claude_directory` 与项目级 `.claude` 的**扫描 / 预览**允许跟随符号链接,目标可在受控 root 外;相对路径仍禁止 `..` / 绝对段。用户级与项目级(含项目 `.claude` 根目录本身是软链)共用该只读契约。 +- **写操作与外部编辑器仍拒绝软链路径**:新建 / 重命名 / 删除,以及 `open_claude_file_in_editor` / `open_project_claude_file_in_editor`,路径一旦经过软链组件必须拒绝;项目侧创建设置文件时若 `.claude` 根是软链也拒绝。 - 日志脱敏字段清单与日志格式规范见 `.claude/rules/projects-tray-diagnostics.md` 的「日志与诊断」一节,不要在两处维护副本。 ## 用量 runtime 与 SQLite diff --git a/CONTEXT.md b/CONTEXT.md index 9884eb7..69d9ebc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,6 +4,32 @@ Code Manager 的领域术语表(glossary),只记录本项目语境下需要统 ## Language +### 配置系统(Config System) + +**配置(Profile)**: +一份完整的 Claude Code 设置单元,引用一个 [Provider](#provider供应商) 并在其 `env` 之上叠加自身 `settings`(认证密钥、permissions/hooks、行为等)。可被[应用](#应用apply)写入 `~/.claude/settings.json`。已移除旧的继承链,配置之间不再互相继承。 +_Avoid_: Profile(裸用英文)、预设/preset(已移除的旧继承模型残留,仅存于 `presetId` 兼容别名)、方案 + +**Provider(供应商)**: +只承载供应商客观信息的单元:连接地址 `env.ANTHROPIC_BASE_URL`、模型映射与元数据(`models`/`docUrl`)。**全部内置只读、不支持自定义、无继承**,且**不含认证密钥、不含 permissions/hooks**——那些都属于[配置](#配置profile)。 +_Avoid_: 预设/preset、渠道、服务商、模型源 + +**应用(Apply)**: +把[配置](#配置profile)解析为最终设置、原子写入 `~/.claude/settings.json`,并在注册表记录[绑定](#绑定binding)的动作。仅预览合并结果、不落盘的是另一回事。 +_Avoid_: 激活、保存、部署、切换(切换只是在 UI 选中另一份配置,不等于应用) + +**绑定(Binding)**: +注册表中记录「当前哪份[配置](#配置profile)挂在 `~/.claude/settings.json` 上」的持久关联(`bindings.user_profile_id`)。被绑定配置被修改时会重新[应用](#应用apply)。 +_Avoid_: 应用状态、关联、挂载 + +**托管(Managed)**: +`~/.claude/settings.json` 的内容由某份[配置](#配置profile)所管理的状态。反之,未被任何配置管理的现存 `settings.json` 是**未托管设置(Unmanaged User Settings)**,可被原地接管(import)为一份新配置,而不立即重写文件。 +_Avoid_: 管理、纳管、接入 + +**设置漂移(Settings Mismatch)**: +已[绑定](#绑定binding)配置的预期内容与磁盘上真实 `~/.claude/settings.json` 不一致的状态(`activeUserSettingsMismatch`)。经 diff 展示后,可选择接管实际内容或重新[应用](#应用apply)。 +_Avoid_: 冲突、不同步、脏配置 + ### 防止休眠(Sleep Prevention) **防止休眠(Sleep Prevention)**: @@ -57,7 +83,7 @@ _Avoid_: 源码、纯文本白名单(判定是「非已知二进制」,不是穷 _Avoid_: 万能链接(Universal Link / App Link,指 https 关联应用)、自定义协议(泛称实现手段时) **配置导入链接(Profile Import Link)**: -一种[深度链接](#深度链接deep-link),动作为将外部 Claude settings 导入为**新配置(Profile)**。打开后须经预览确认才入库,且**不自动应用/绑定**到 `~/.claude/settings.json`。 +一种[深度链接](#深度链接deep-link),动作为将外部 Claude settings 导入为一份新[配置](#配置profile)。打开后须经预览确认才入库,且**不自动应用/绑定**到 `~/.claude/settings.json`。 _Avoid_: 一键应用链接、配置同步链接、远程配置下发 **内嵌载荷导入(Embedded Payload Import)**: @@ -68,7 +94,21 @@ _Avoid_: 附件导入、剪贴板导入(那是别的入口) [配置导入链接](#配置导入链接profile-import-link)的一种来源:query `url` 指向一份 HTTPS 上的 settings JSON,由应用拉取后再走同一套预览导入。 _Avoid_: 在线配置中心、配置订阅、远程同步 -### Token 用量(Token Usage) +### 历史、统计与用量(History, Stats & Usage) + +三者数据源不同,互不混用。 + +**项目(Project)**: +Claude Code 的会话工作目录视图,数据源是 `~/.claude/history.jsonl`。 +_Avoid_: 工作区、仓库、会话(会话是项目内的单次对话) + +**统计(Stats)**: +基于 `~/.claude.json` 的账户级统计视图。 +_Avoid_: 用量(数据源不同,见下)、报表 + +**用量(Usage)**: +扫描 `~/.claude/projects/**/*.jsonl` 得到的 Token 消耗与费用视图;[缓存命中率](#缓存命中率cache-hit-rate)等指标都归此。 +_Avoid_: 统计(数据源不同,见上)、账单 **缓存命中率(Cache Hit Rate)**: 在筛选范围内,缓存读取占全部输入侧 Token 的比例。口径固定为 `cacheRead / (input + cacheCreate + cacheRead)`;输出 Token 不计入分母。无输入时记为 0%。 @@ -81,3 +121,27 @@ _Avoid_: 平均命中率、模型命中率均值 **模型缓存命中率(Model Cache Hit Rate)**: 单个模型在同一筛选与时间桶内、仅用该模型 token 算出的[缓存命中率](#缓存命中率cache-hit-rate)。与[总体缓存命中率](#总体缓存命中率overall-cache-hit-rate)并列展示,便于对比模型缓存表现。 _Avoid_: 模型缓存率(省略「命中」)、用总体分母去除模型分子 + +### 记忆(Memory) + +**记忆(Memory)**: +Code Manager 管理的 Claude Code 用户级指令文件,即 `~/.claude/CLAUDE.md` 与 `~/.claude/rules/*.md`。**不含** Claude Code 的 auto memory——那是独立机制,当前不扫描、不导入、不写入。 +_Avoid_: 指令、提示词、Claude Code auto memory(独立机制,不归本页管) + +**claude 记忆(Claude Memory)**: +`claude` 类型的[记忆](#记忆memory),同一时间只能启用一个,启用即写入 `~/.claude/CLAUDE.md`。 +_Avoid_: 主记忆、全局记忆 + +**rule 记忆(Rule Memory)**: +`rule` 类型的[记忆](#记忆memory),可同时启用多个,分别写入 `~/.claude/rules/`,携带结构化 `pathPatterns` 决定按路径触发。 +_Avoid_: 规则文件(泛指)、path rule + +### Skills + +**Skill**: +一个带 `SKILL.md` 的目录单元。启用时位于 `~/.claude/skills//`,禁用时移到应用数据目录的 `skills-disabled//`;id 只含小写字母、数字与连字符。 +_Avoid_: 技能(项目 UI 与代码统一用 Skill)、插件、能力 + +**软链接 Skill(Symlinked Skill)**: +本体为目录级符号链接的 [Skill](#skill)。只读查看其目标目录的 `SKILL.md`,不递归读取支持文件,不可经应用编辑。与[软链条目](#软链条目symlink-entry)同源但专指 Skill。 +_Avoid_: 外部 Skill、引用 Skill diff --git a/docs/adr/0002-directory-overview-readonly-follow-symlinks.md b/docs/adr/0002-directory-overview-readonly-follow-symlinks.md index 6d844fd..a0eca84 100644 --- a/docs/adr/0002-directory-overview-readonly-follow-symlinks.md +++ b/docs/adr/0002-directory-overview-readonly-follow-symlinks.md @@ -7,13 +7,14 @@ ## Decision 1. **扫描收录软链**:目录软链可展开并递归列子项;文件软链可点开预览。顶栏统计为「含 N 个软链接」,不再「跳过」。 -2. **预览只读跟随,允许出界**:相对路径仍禁止 `..` / 绝对段;解析时允许跟随符号链接,目标可在 root 外。写操作(新建 / 重命名 / 删除)在路径上一旦经过软链则全部拒绝。 -3. **损坏与环**:悬空软链仍显示并标损坏;以 canonicalize 真实路径去重,再次遇到标循环且不递归。 -4. **二进制预览**:按已知二进制扩展名(及 `.DS_Store` 等特殊名)黑名单判定;其余一律按 UTF-8 / lossy 文本预览,不再因内容含 `NUL` 清空。 -5. **范围**:用户级 `~/.claude` 总览与项目级 `.claude` 浏览共用同一契约。 +2. **预览只读跟随,允许出界**:相对路径仍禁止 `..` / 绝对段;解析时允许跟随符号链接,目标可在 root 外。 +3. **写边界(含外部编辑器)**:新建 / 重命名 / 删除,以及 **用默认编辑器打开**(可写回磁盘),在路径上一旦经过软链则全部拒绝。用户级 `open_claude_file_in_editor` 与项目级 `open_project_claude_file_in_editor` 同一语义。 +4. **损坏与环**:悬空软链仍显示并标损坏;以 canonicalize 真实路径去重,再次遇到标循环且不递归。 +5. **二进制预览**:按已知二进制扩展名(及 `.DS_Store` 等特殊名)黑名单判定;其余一律按 UTF-8 / lossy 文本预览,不再因内容含 `NUL` 清空。 +6. **范围**:用户级 `~/.claude` 总览与项目级 `.claude` 浏览共用同一契约。 ## Consequences -- 读预览的可达面扩大到软链指向的任意本地目标;风险靠「只读 + 大小截断 + 写拒绝」收敛。 +- 读预览的可达面扩大到软链指向的任意本地目标;风险靠「只读 + 大小截断 + 写/编辑器打开拒绝」收敛。 - UI 必须标明软链与目标路径,避免用户把出界内容误认为 root 内真文件。 -- 删除 / 修复悬空软链需在终端或源目录完成,总览不提供写入口。 +- 删除 / 修复悬空软链、或编辑软链目标内容,需在终端或源目录完成;总览预览可读,但不提供经软链的写入口(含外部编辑器)。 diff --git a/docs/adr/0003-deeplink-profile-import.md b/docs/adr/0003-deeplink-profile-import.md index 2e7ec10..d6135a6 100644 --- a/docs/adr/0003-deeplink-profile-import.md +++ b/docs/adr/0003-deeplink-profile-import.md @@ -13,7 +13,7 @@ - `payload` 与 `url` 都缺或都有 → 拒绝。可选 query `name` / `description` 仅预填导入对话框,不参与签名或信任。 3. **载荷契约对齐文件导入**:内容是**裸 Claude settings JSON**(不是带 `providerId` 的私有 envelope)。校验与入库语义对齐现有 `preview_profile_import` / `import_profile_from_file`:预览确认后**新建配置**,`providerId` 为空,**不自动应用/绑定**。 4. **密钥策略**:不强制剥离密钥。预览若检测到认证密钥,必须**风险横幅 + 勾选确认**后才能导入。导出侧新增「复制 Deep Link」,默认**不含密钥**;用户显式包含密钥时,复制前使用**同一档确认**。 -5. **生命周期与 UI**:单实例;冷启动与热启动都处理链接;多条链接**排队**(取消或失败后自动处理下一条,取消不清空整队)。成功解析后聚焦 **main** 窗口、切到配置页、打开与文件导入同一套预览 UI。三端(macOS / Windows / Linux)均注册 scheme。 +5. **生命周期与 UI**:单实例;冷启动与热启动都处理链接;多条链接**排队**(取消或失败后自动处理下一条,取消不清空整队)。队列权威源在后端 pending:前端 `peek` 队头做预览,用户确认导入 / 取消 / 解析失败后 `ack` 移除;切页不 ack、不丢链。成功解析后聚焦 **main** 窗口、切到配置页、打开与文件导入同一套预览 UI。三端(macOS / Windows / Linux)均注册 scheme。 6. **首期明确不做**:自动 apply、应用代托管 JSON、主机白名单、payload 压缩、分享 envelope、多实例、仅热启动处理。 ## Consequences diff --git a/docs/user-manual.md b/docs/user-manual.md index 179b51a..52e5a14 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -131,6 +131,18 @@ After you click Test Model, a request is sent based on the current edits. The re Model testing requires a valid `ANTHROPIC_AUTH_TOKEN` and an accessible model API. +### Deep Link Import + +You can send a Claude settings document into Code Manager via a system URL for preview-and-import. The scheme/path is `code-manager://profiles/import`. + +- **Two mutually exclusive sources**: embedded `payload=` (base64url-encoded bare settings JSON, about 16KB decoded), or remote `url=` (HTTPS only; the app fetches then imports, with SSRF protections and size/redirect limits). +- **Optional query params**: `name` and `description` only prefill the import dialog. +- **Does not auto-enable**: after a successful resolve, the app switches to the Configurations page and opens the same preview dialog as file import. Confirming **creates a new configuration** with an empty `providerId` and **does not** automatically write or bind `~/.claude/settings.json`. +- **Secrets**: if the payload contains authentication-like fields, you must acknowledge the risk before import. “Copy Deep Link” from an existing profile excludes secrets by default; including secrets requires the same acknowledgement. +- **Queue**: multiple links are queued. Closing the preview or finishing an import continues with the next item. Leaving the Configurations page and returning restores unconfirmed links (the authoritative queue lives in the app backend). + +The scheme is registered for packaged installs on macOS / Windows / Linux; Linux/Windows may need extra registration in some development setups. + ## Providers Providers are all built-in and read-only. They carry only objective provider information (the connection endpoint `ANTHROPIC_BASE_URL`, the model mapping, and optional additional environment variables) and contain no authentication keys. They currently cover Anthropic, DeepSeek, Zhipu GLM Coding Plan, Kimi Code Plan, MiniMax Token Plan, Xiaomi MiMo Token Plan, OpenRouter, Volcengine Ark Coding Plan, Alibaba Cloud Bailian Coding Plan, Wanjie Ark, and Ollama. @@ -300,6 +312,18 @@ The Settings entry is in the lower-left corner. Settings are grouped into Interf > The entire Device Integration group is shown only on macOS; other platforms do not provide the LED or the global session focus shortcut. +### Prevent Sleep (macOS only) + +When Claude Code sessions need to run for a long time, you can stop the Mac from entering idle sleep. + +- **Mode (one of three)**: + - **Off**: do not interfere with system sleep. + - **While active**: stay awake while there is a running Claude Code session; idle sleep is allowed again when all sessions end. + - **Always**: stay awake unconditionally (still subject to the display option below). +- **Keep display awake**: orthogonal to the mode. By default only **system idle sleep** is blocked and the display may still dim per system policy; enabling this keeps the display on as well. +- **Live status**: when the mode is not Off, Settings shows a status dot such as “Keeping awake” or “Idle, may sleep”; the tray menu can also switch modes. +- **Limits**: macOS only; hardware policies such as closing the lid may still sleep the machine; quitting the app releases the power assertion. + ### System Notifications and Pricing - System notifications: used when a Claude session enters the pending state, and in scenarios such as when clicking a session to jump but terminal location fails. When enabled, system notification permission is requested first; if permission is denied, the setting stays off. diff --git a/docs/user-manual.zh-CN.md b/docs/user-manual.zh-CN.md index 4ea3b90..526af0c 100644 --- a/docs/user-manual.zh-CN.md +++ b/docs/user-manual.zh-CN.md @@ -131,6 +131,18 @@ Skills 对应 `~/.claude/skills//SKILL.md`。启用的 Skill 保存在 `~/.c 模型测试需要有效的 `ANTHROPIC_AUTH_TOKEN` 和可访问的模型 API。 +### 深度链接导入 + +可用系统 URL 把一份 Claude settings 送进 Code Manager 做预览导入,scheme为 `code-manager://profiles/import`。 + +- **两种来源(互斥)**:内嵌 `payload=`(base64url 编码的裸 settings JSON,解码后约 16KB 上限),或远端 `url=`(仅 HTTPS,应用拉取后导入;有 SSRF 防护与体积/跳转限制)。 +- **可选参数**:`name`、`description` 仅用于预填导入对话框。 +- **不自动启用**:解析成功后会切到配置页并打开与文件导入相同的预览对话框;确认后**新建配置**,`providerId` 为空,且**不会**自动写入或绑定 `~/.claude/settings.json`。 +- **密钥**:若载荷含认证类字段,须勾选风险确认后才能导入。从已有配置「复制 Deep Link」时默认不含密钥;显式包含密钥时同样需勾选确认。 +- **多条排队**:连续打开多条链接会排队处理;关闭预览或导入成功后继续下一条。离开配置页再回来,未确认的链接仍会恢复(权威队列在应用后端)。 + +macOS / Windows / Linux 在安装包场景下均可注册 scheme;开发态下 Linux/Windows 可能需额外注册。 + ## 供应商 Provider 供应商均为内置且只读,只承载供应商客观信息(连接地址 `ANTHROPIC_BASE_URL`、模型映射与可选附加环境变量),不含认证密钥。当前覆盖 Anthropic、DeepSeek、智谱 GLM Coding Plan、Kimi Code Plan、MiniMax Token Plan、小米 MiMo Token Plan、OpenRouter、火山方舟 Coding Plan、阿里云百炼 Coding Plan、万界方舟和 Ollama。 @@ -300,6 +312,18 @@ Code Manager 常驻系统托盘(菜单栏),菜单分两部分: > 设备联动整组仅在 macOS 显示;其它平台不提供 LED 与全局会话聚焦快捷键。 +### 防止休眠(仅 macOS) + +在 Claude Code 会话需要长时间运行时,可阻止电脑因空闲进入休眠。 + +- **模式(三选一)**: + - **关闭**:不干预系统休眠。 + - **仅活动时**:有运行中的 Claude Code 会话时保持唤醒;会话全部结束后可正常休眠。 + - **始终**:无条件保持唤醒(仍受「屏幕常亮」开关约束)。 +- **同时保持屏幕常亮**:与模式正交。默认只阻止**系统空闲休眠**,屏幕仍可按系统策略熄灭;开启后连显示器一起保持点亮。 +- **实时状态**:模式非关闭时,设置区会显示「正在保持唤醒」或「空闲,可休眠」等状态点;托盘菜单也可切换模式。 +- **边界**:仅 macOS 生效;合盖等硬件策略可能仍导致睡眠;退出应用会释放电源断言。 + ### 系统通知与计价 - 系统通知:用于 Claude 会话进入待处理状态,以及点击会话跳转但终端定位失败等场景。开启时会先请求系统通知权限;权限被拒绝时设置保持关闭。 diff --git a/src-tauri/src/claude_directory.rs b/src-tauri/src/claude_directory.rs index 9f3fbd9..62d3471 100644 --- a/src-tauri/src/claude_directory.rs +++ b/src-tauri/src/claude_directory.rs @@ -176,13 +176,12 @@ pub fn read_claude_file_preview(path: String) -> Result Result<(), String> { let result = (|| { let root = claude_dir()?; - let rel_path = validate_relative_claude_path(&path)?; - let resolved = resolve_path_for_read(&root, &rel_path)?; - if resolved.is_broken { - return Err(SYMLINK_TARGET_UNAVAILABLE_ERROR.to_string()); - } - let metadata = fs::metadata(&resolved.logical_path) - .map_err(|e| mask_io_error("读取文件元数据", &e))?; + // 外部编辑器可写回文件,与项目侧 open_project_claude_file_in_editor 一致: + // 按写边界解析,拒绝路径上任何软链组件(禁止间接改 root 外目标)。 + // 预览仍走 resolve_path_for_read(ADR 0002 只读跟随)。 + let rel_path = validate_relative_claude_operation_path(&path)?; + let target = resolve_operation_path_inside_root(&root, &rel_path)?; + let metadata = fs::metadata(&target).map_err(|e| mask_io_error("读取文件元数据", &e))?; if !metadata.is_file() { return Err("只能用默认编辑器打开 ~/.claude 内的文件".to_string()); } @@ -191,7 +190,7 @@ pub fn open_claude_file_in_editor(path: String) -> Result<(), String> { .default_editor_app .as_deref() .ok_or_else(|| "请先在设置中选择默认编辑器".to_string())?; - crate::native_open::open_path_in_editor(&resolved.logical_path, editor) + crate::native_open::open_path_in_editor(&target, editor) })(); crate::logging::log_command_result("claude_directory.open_editor", &result, |_| { format!("path={}", crate::utils::truncate(&path, 160)) @@ -1276,6 +1275,38 @@ mod tests { ) .expect_err("经软链路径禁止写入"); assert!(write_err.contains("软链接路径只读") || write_err.contains("只能操作")); + + // 外部编辑器按写边界:经软链路径(含出界目标)必须拒绝 + let open_via_symlink = + open_claude_file_in_editor_in_root(&env.claude_dir(), "skills/linked/SKILL.md") + .expect_err("经软链路径禁止用外部编辑器打开"); + assert!( + open_via_symlink.contains("软链接路径只读") || open_via_symlink.contains("只能操作"), + "unexpected open error: {open_via_symlink}" + ); + } + + /// 测试入口:与 command 同写边界语义,不依赖默认编辑器配置 + fn open_claude_file_in_editor_in_root(root: &Path, path: &str) -> Result { + let rel_path = validate_relative_claude_operation_path(path)?; + let target = resolve_operation_path_inside_root(root, &rel_path)?; + let metadata = fs::metadata(&target).map_err(|e| mask_io_error("读取文件元数据", &e))?; + if !metadata.is_file() { + return Err("只能用默认编辑器打开 ~/.claude 内的文件".to_string()); + } + Ok(target) + } + + #[test] + fn open_in_editor_allows_plain_file_inside_root() { + let env = TestEnv::new("open-editor-plain"); + fs::write(env.claude_dir().join("settings.json"), "{}\n").expect("应可写入"); + let target = open_claude_file_in_editor_in_root(&env.claude_dir(), "settings.json") + .expect("root 内普通文件应可解析为编辑器目标"); + assert_eq!( + fs::canonicalize(&target).expect("canonicalize target"), + fs::canonicalize(env.claude_dir().join("settings.json")).expect("canonicalize file") + ); } #[test] diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index a37c33e..e28da50 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -2765,10 +2765,12 @@ fn insert_imported_profile( let mut registry = load_registry()?; let now = crate::utils::current_rfc3339_timestamp(); let trimmed_name = name.trim(); + // 名称优先用调用方传入;仍为空时用 import-<短 id> 技术兜底,避免硬编码英文 UI 文案 + let profile_id = Uuid::new_v4().to_string(); let profile = ConfigProfile { - id: Uuid::new_v4().to_string(), + id: profile_id.clone(), name: if trimmed_name.is_empty() { - "Imported Profile".to_string() + format!("import-{}", &profile_id[..8]) } else { trimmed_name.to_string() }, @@ -2967,6 +2969,51 @@ mod tests { std::env::remove_var("CODE_MANAGER_APP_DATA_DIR_OVERRIDE"); } + // 敏感键判定与前端 isSensitiveSettingsKey 逐条一致,但两端各自内联维护 + // 敏感键判定规则(精确匹配 + 前后缀/子串模式)。 + // 共享语料是预期结果的单一事实源,不是键清单;两端各写测试对照它,任一端漂移即红。 + // 前端对照断言见 src/lib/__tests__/sensitive-key-parity.test.ts。 + #[test] + fn is_sensitive_settings_key_matches_shared_fixture() { + #[derive(serde::Deserialize)] + struct Case { + key: String, + sensitive: bool, + } + + // 与前端 parity 测试的 MIN_FIXTURE_CASES 对齐,防止语料清空后空跑仍绿 + const MIN_FIXTURE_CASES: usize = 20; + + let cases: Vec = serde_json::from_str(include_str!( + "../tests/fixtures/sensitive-settings-keys.json" + )) + .expect("解析敏感键 parity 语料失败"); + + assert!( + cases.len() >= MIN_FIXTURE_CASES, + "敏感键 parity 语料过少:got {}, want >= {}", + cases.len(), + MIN_FIXTURE_CASES + ); + assert!( + cases.iter().any(|c| c.sensitive), + "敏感键 parity 语料缺少 sensitive=true 用例" + ); + assert!( + cases.iter().any(|c| !c.sensitive), + "敏感键 parity 语料缺少 sensitive=false 用例" + ); + + for case in cases { + assert_eq!( + is_sensitive_settings_key(&case.key), + case.sensitive, + "key={}", + case.key + ); + } + } + fn sample_profile(id: &str, provider_id: Option<&str>, settings: Value) -> ConfigProfile { ConfigProfile { id: id.to_string(), diff --git a/src-tauri/src/deep_link.rs b/src-tauri/src/deep_link.rs index 446f46c..a2d44f8 100644 --- a/src-tauri/src/deep_link.rs +++ b/src-tauri/src/deep_link.rs @@ -20,21 +20,80 @@ pub const DEEP_LINK_SCHEME: &str = "code-manager"; pub const PROFILE_IMPORT_PATH: &str = "profiles/import"; /// 内嵌 payload 解码后硬上限(字节)。 pub const MAX_PAYLOAD_DECODED_BYTES: usize = 16 * 1024; +/// 解码前对 base64 串的粗上限(约 4/3 膨胀 + 余量),避免先完整解码再拒。 +pub const MAX_PAYLOAD_ENCODED_CHARS: usize = MAX_PAYLOAD_DECODED_BYTES * 4 / 3 + 8; /// 远端响应体上限。 pub const MAX_REMOTE_BODY_BYTES: usize = 256 * 1024; /// 远端 HTTPS 最大跳转次数。 pub const MAX_REMOTE_REDIRECTS: usize = 3; /// 远端拉取超时。 pub const REMOTE_FETCH_TIMEOUT: Duration = Duration::from_secs(10); +/// pending 导入队列硬上限;超出时丢弃最旧项并记 warn。 +pub const MAX_PENDING_PROFILE_IMPORT_DEEP_LINKS: usize = 20; const EVENT_PROFILE_IMPORT_DEEP_LINK: &str = "profile-import-deep-link"; -/// 冷启动时前端尚未订阅事件,先入队;前端 `drain` 与热事件合并处理。 +/// 冷启动 / 热启动共用的配置导入 deep link 队列。 +/// +/// 权威源始终在此:前端只能 peek 队头做预览,用户确认导入 / 取消 / 解析失败后 +/// 再 `ack` 移除;切页卸载 UI 不会丢链(与破坏性 drain 不同)。 #[derive(Default)] pub struct PendingProfileImportDeepLinks { urls: Mutex>, } +impl PendingProfileImportDeepLinks { + fn peek_front(&self) -> Result, String> { + let guard = self + .urls + .lock() + .map_err(|_| "深度链接队列锁异常".to_string())?; + Ok(guard.front().cloned()) + } + + fn len(&self) -> Result { + let guard = self + .urls + .lock() + .map_err(|_| "深度链接队列锁异常".to_string())?; + Ok(guard.len()) + } + + /// 仅当队头等于 `url` 时 pop,避免误删尚未展示的后续项。 + fn ack_front(&self, url: &str) -> Result { + let mut guard = self + .urls + .lock() + .map_err(|_| "深度链接队列锁异常".to_string())?; + if guard.front().map(String::as_str) == Some(url) { + guard.pop_front(); + Ok(true) + } else { + Ok(false) + } + } + + fn push_url(&self, url: String) -> Result<(), String> { + let mut guard = self + .urls + .lock() + .map_err(|_| "深度链接队列锁异常".to_string())?; + // 队列内全局去重:同一 URL 已在待处理中则跳过 + if guard.iter().any(|existing| existing == &url) { + return Ok(()); + } + while guard.len() >= MAX_PENDING_PROFILE_IMPORT_DEEP_LINKS { + let dropped = guard.pop_front(); + log::warn!( + "event=deep_link.enqueue status=drop_oldest reason=queue_full dropped={}", + dropped.as_deref().unwrap_or("") + ); + } + guard.push_back(url); + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ResolvedProfileImportDeepLink { @@ -120,17 +179,39 @@ pub fn build_profile_import_deep_link(id: String, include_secrets: bool) -> Resu Ok(url.into()) } -/// 取出冷启动积压的配置导入 deep link(取出后清空)。 +/// 窥视队头 URL,不移除。无待处理时返回 `None`。 +#[tauri::command] +#[specta::specta] +pub fn peek_pending_profile_import_deep_link( + pending: State<'_, PendingProfileImportDeepLinks>, +) -> Result, String> { + pending.peek_front() +} + +/// 待处理条数(含当前队头)。用于「还有待处理的导入链接」提示。 #[tauri::command] #[specta::specta] -pub fn drain_pending_profile_import_deep_links( +pub fn count_pending_profile_import_deep_links( pending: State<'_, PendingProfileImportDeepLinks>, -) -> Result, String> { - let mut guard = pending - .urls - .lock() - .map_err(|_| "深度链接队列锁异常".to_string())?; - Ok(guard.drain(..).collect()) +) -> Result { + pending.len() +} + +/// 确认结束当前队头:仅当队头等于 `url` 时移除。 +/// 导入成功、用户取消、解析失败后调用;切页不应调用。 +#[tauri::command] +#[specta::specta] +pub fn ack_profile_import_deep_link( + pending: State<'_, PendingProfileImportDeepLinks>, + url: String, +) -> Result { + let removed = pending.ack_front(&url)?; + if removed { + log::info!("event=deep_link.ack status=ok"); + } else { + log::warn!("event=deep_link.ack status=skip reason=front_mismatch"); + } + Ok(removed) } /// 注册 deep-link 监听与 pending 队列;在 setup 中调用。 @@ -177,12 +258,11 @@ fn enqueue_profile_import_deep_link(app: &AppHandle, url: String) { return; } log::info!("event=deep_link.enqueue status=ok action=profiles.import"); - // 唯一事实源是 pending 队列;事件仅唤醒前端 drain,避免「事件 + 队列」双投递 + // 唯一事实源是 pending 队列;事件仅唤醒前端 peek/预览,避免「事件 + 队列」双投递 if let Some(pending) = app.try_state::() { - if let Ok(mut guard) = pending.urls.lock() { - if guard.back() != Some(&url) { - guard.push_back(url); - } + if let Err(error) = pending.push_url(url) { + log::warn!("event=deep_link.enqueue status=err error={error}"); + return; } } let _ = app.emit(EVENT_PROFILE_IMPORT_DEEP_LINK, ()); @@ -253,10 +333,7 @@ fn parse_profile_import_deep_link(raw: &str) -> Result Result { if trimmed.is_empty() { return Err("payload 为空".to_string()); } + // 先按编码长度粗拒,避免超大字符串先完整解码再失败 + if trimmed.len() > MAX_PAYLOAD_ENCODED_CHARS { + return Err(format!( + "payload 编码长度超过 {} 字符上限", + MAX_PAYLOAD_ENCODED_CHARS + )); + } let bytes = URL_SAFE_NO_PAD .decode(trimmed) .or_else(|_| URL_SAFE.decode(trimmed)) @@ -476,8 +560,10 @@ fn is_blocked_ip(ip: IpAddr) -> bool { || v4.is_multicast() // CGNAT 100.64.0.0/10 || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64) - // 文档/基准 198.18.0.0/15、192.0.2.0/24 等一并拦截常见非公网 + // 文档用 192.0.2.0/24、198.51.100.0/24、203.0.113.0/24 || v4.is_documentation() + // 基准测试网 198.18.0.0/15(is_documentation 不覆盖) + || (v4.octets()[0] == 198 && (v4.octets()[1] & 0xfe) == 18) || (v4.octets()[0] == 0) } IpAddr::V6(v6) => { @@ -510,6 +596,16 @@ mod tests { assert!(matches!(parsed.source, ProfileImportSource::Payload(_))); } + #[test] + fn parse_payload_link_leaves_name_empty_when_absent() { + let settings = br#"{"model":"claude"}"#; + let payload = URL_SAFE_NO_PAD.encode(settings); + let raw = format!("code-manager://profiles/import?payload={payload}"); + let parsed = parse_profile_import_deep_link(&raw).unwrap(); + assert_eq!(parsed.name, ""); + assert!(matches!(parsed.source, ProfileImportSource::Payload(_))); + } + #[test] fn parse_rejects_both_payload_and_url() { let err = parse_profile_import_deep_link( @@ -534,6 +630,13 @@ mod tests { assert!(err.contains("上限")); } + #[test] + fn decode_payload_rejects_oversized_encoded_string_before_decode() { + let oversized = "A".repeat(MAX_PAYLOAD_ENCODED_CHARS + 1); + let err = decode_payload(&oversized).unwrap_err(); + assert!(err.contains("编码长度"), "unexpected: {err}"); + } + #[test] fn decode_payload_accepts_valid_json_bytes() { let payload = URL_SAFE_NO_PAD.encode(br#"{"model":"claude"}"#); @@ -541,6 +644,80 @@ mod tests { assert!(raw.contains("claude")); } + #[test] + fn pending_queue_peek_does_not_remove() { + let pending = PendingProfileImportDeepLinks::default(); + pending + .push_url("code-manager://profiles/import?payload=a".into()) + .unwrap(); + pending + .push_url("code-manager://profiles/import?payload=b".into()) + .unwrap(); + assert_eq!(pending.len().unwrap(), 2); + assert_eq!( + pending.peek_front().unwrap().as_deref(), + Some("code-manager://profiles/import?payload=a") + ); + assert_eq!(pending.len().unwrap(), 2); + } + + #[test] + fn pending_queue_ack_only_pops_matching_front() { + let pending = PendingProfileImportDeepLinks::default(); + pending + .push_url("code-manager://profiles/import?payload=a".into()) + .unwrap(); + pending + .push_url("code-manager://profiles/import?payload=b".into()) + .unwrap(); + assert!(!pending + .ack_front("code-manager://profiles/import?payload=b") + .unwrap()); + assert_eq!(pending.len().unwrap(), 2); + assert!(pending + .ack_front("code-manager://profiles/import?payload=a") + .unwrap()); + assert_eq!( + pending.peek_front().unwrap().as_deref(), + Some("code-manager://profiles/import?payload=b") + ); + assert_eq!(pending.len().unwrap(), 1); + } + + #[test] + fn pending_queue_skips_duplicate_anywhere() { + let pending = PendingProfileImportDeepLinks::default(); + pending + .push_url("code-manager://profiles/import?payload=a".into()) + .unwrap(); + pending + .push_url("code-manager://profiles/import?payload=b".into()) + .unwrap(); + // 与队中非队尾项重复也应跳过 + pending + .push_url("code-manager://profiles/import?payload=a".into()) + .unwrap(); + assert_eq!(pending.len().unwrap(), 2); + } + + #[test] + fn pending_queue_drops_oldest_when_full() { + let pending = PendingProfileImportDeepLinks::default(); + for i in 0..(MAX_PENDING_PROFILE_IMPORT_DEEP_LINKS + 3) { + pending + .push_url(format!("code-manager://profiles/import?payload={i}")) + .unwrap(); + } + assert_eq!( + pending.len().unwrap(), + MAX_PENDING_PROFILE_IMPORT_DEEP_LINKS + ); + assert_eq!( + pending.peek_front().unwrap().as_deref(), + Some("code-manager://profiles/import?payload=3") + ); + } + #[test] fn settings_contain_secrets_detects_non_empty_token() { let value = json!({ @@ -578,6 +755,10 @@ mod tests { assert!(is_blocked_ip("169.254.169.254".parse().unwrap())); assert!(is_blocked_ip("::1".parse().unwrap())); assert!(!is_blocked_ip("1.1.1.1".parse().unwrap())); + // 基准测试网 198.18.0.0/15 + assert!(is_blocked_ip("198.18.0.1".parse().unwrap())); + assert!(is_blocked_ip("198.19.255.255".parse().unwrap())); + assert!(!is_blocked_ip("198.20.0.1".parse().unwrap())); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b97de35..9c88ffe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,7 +45,8 @@ use config::{ test_profile_model, upsert_profile, }; use deep_link::{ - build_profile_import_deep_link, drain_pending_profile_import_deep_links, + ack_profile_import_deep_link, build_profile_import_deep_link, + count_pending_profile_import_deep_links, peek_pending_profile_import_deep_link, resolve_profile_import_deep_link, }; use history::{ @@ -115,7 +116,9 @@ fn build_specta_builder() -> tauri_specta::Builder { import_profile_from_settings_json, resolve_profile_import_deep_link, build_profile_import_deep_link, - drain_pending_profile_import_deep_links, + peek_pending_profile_import_deep_link, + count_pending_profile_import_deep_links, + ack_profile_import_deep_link, test_profile_model, set_app_preferences, toggle_floating_widget, diff --git a/src-tauri/src/project.rs b/src-tauri/src/project.rs index 7ba170d..eca38f3 100644 --- a/src-tauri/src/project.rs +++ b/src-tauri/src/project.rs @@ -1955,17 +1955,25 @@ fn reject_project_claude_root_symlink(claude_root: &Path) -> Result<(), String> } } -fn project_claude_root(project: &str) -> Result { +/// 只读总览/预览入口:允许 `.claude` 本身是目录软链(与用户级只读跟随契约一致)。 +fn project_claude_root_for_read(project: &str) -> Result { let project_path = validate_project_path(project)?; let claude_root = project_path.join(".claude"); - reject_project_claude_root_symlink(&claude_root)?; if !claude_root.is_dir() { return Err("项目 .claude/ 目录不存在".to_string()); } Ok(claude_root) } -/// 解析项目级 .claude/ 内的相对路径,沿用 claude_directory 的校验,错误文案改为项目场景 +/// 写操作入口:`.claude` 根为软链时拒绝(写路径不跟随出界)。 +fn project_claude_root_for_write(project: &str) -> Result { + let project_path = validate_project_path(project)?; + let claude_root = project_path.join(".claude"); + reject_project_claude_root_symlink(&claude_root)?; + Ok(claude_root) +} + +/// 解析项目级 .claude/ 内的相对路径(写语义:禁止软链组件),错误文案改为项目场景 fn resolve_project_claude_file(claude_root: &Path, relative_path: &str) -> Result { let rel_path = crate::claude_directory::validate_relative_claude_path(relative_path) .map_err(|_| "只能访问项目 .claude/ 内的文件".to_string())?; @@ -1979,7 +1987,7 @@ pub fn get_project_claude_directory_overview( project: &str, ) -> Result { let result = (|| { - let claude_root = project_claude_root(project)?; + let claude_root = project_claude_root_for_read(project)?; crate::claude_directory::scan_claude_directory_with_options( &claude_root, crate::claude_directory::ScanOptions { @@ -2005,14 +2013,20 @@ pub fn get_project_claude_file_preview( relative_path: String, ) -> Result { let result = (|| { - let claude_root = project_claude_root(project)?; - // 先用项目侧错误文案过一次路径校验;通过后再调全局预览实现 - let _ = resolve_project_claude_file(&claude_root, &relative_path)?; + let claude_root = project_claude_root_for_read(project)?; + // 与用户级总览共用只读跟随契约(含内部软链 / 根为软链) crate::claude_directory::read_claude_file_preview_from_root( &claude_root, &relative_path, PROJECT_CLAUDE_PREVIEW_MAX_BYTES, ) + .map_err(|err| { + if err.contains("~/.claude") { + err.replace("~/.claude", "项目 .claude/") + } else { + err + } + }) })(); crate::logging::log_command_result("project.claude_directory_preview", &result, |preview| { format!( @@ -2037,8 +2051,7 @@ pub fn create_project_claude_settings_file( if !project_path.is_dir() { return Err("项目目录不存在".to_string()); } - let claude_root = project_path.join(".claude"); - reject_project_claude_root_symlink(&claude_root)?; + let claude_root = project_claude_root_for_write(project)?; let filename = match scope { ProjectClaudeSettingsScope::Shared => "settings.json", ProjectClaudeSettingsScope::Local => "settings.local.json", @@ -2068,7 +2081,11 @@ pub fn open_project_claude_file_in_editor( relative_path: String, ) -> Result<(), String> { let result = (|| { - let claude_root = project_claude_root(project)?; + // 外部编辑器打开按写边界:根为软链或路径经软链时拒绝 + let claude_root = project_claude_root_for_write(project)?; + if !claude_root.is_dir() { + return Err("项目 .claude/ 目录不存在".to_string()); + } let target = resolve_project_claude_file(&claude_root, &relative_path)?; let metadata = fs::metadata(&target).map_err(|e| format!("读取文件元数据失败: {}", e))?; if !metadata.is_file() { @@ -2864,16 +2881,39 @@ mod tests { #[cfg(unix)] #[test] - fn get_project_claude_directory_overview_rejects_root_symlink() { + fn get_project_claude_directory_overview_follows_root_symlink() { let project = TestDir::new(); let outside = TestDir::new(); std::fs::write(outside.path().join("outside.md"), "secret\n").unwrap(); create_test_symlink(outside.path(), &project.path().join(".claude")); - let err = get_project_claude_directory_overview(project.path().to_str().unwrap()) - .expect_err("项目 .claude/ 是软链时应拒绝浏览"); + let overview = get_project_claude_directory_overview(project.path().to_str().unwrap()) + .expect("项目 .claude/ 为软链时应可只读浏览"); + let names: Vec<&str> = overview + .entries + .iter() + .map(|entry| entry.path.as_str()) + .collect(); + assert!( + names.iter().any(|n| n.contains("outside.md")), + "应列出软链目标内文件, got {names:?}" + ); + } - assert!(err.contains(".claude/"), "非预期错误: {err}"); + #[cfg(unix)] + #[test] + fn get_project_claude_file_preview_follows_root_symlink() { + let project = TestDir::new(); + let outside = TestDir::new(); + std::fs::write(outside.path().join("readme.md"), "via-root-link\n").unwrap(); + create_test_symlink(outside.path(), &project.path().join(".claude")); + + let preview = get_project_claude_file_preview( + project.path().to_str().unwrap(), + "readme.md".to_string(), + ) + .expect("根为软链时应可只读预览"); + assert!(preview.content.contains("via-root-link")); } #[test] @@ -2888,7 +2928,9 @@ mod tests { get_project_claude_file_preview(project.path().to_str().unwrap(), bad.to_string()) .expect_err(&format!("路径 {bad} 应被拒")); assert!( - err.contains("项目 .claude/") || err.contains("项目路径"), + err.contains("项目 .claude/") + || err.contains("项目路径") + || err.contains("只能读取"), "非预期错误: {err}" ); } diff --git a/src-tauri/tests/fixtures/sensitive-settings-keys.json b/src-tauri/tests/fixtures/sensitive-settings-keys.json new file mode 100644 index 0000000..511faf9 --- /dev/null +++ b/src-tauri/tests/fixtures/sensitive-settings-keys.json @@ -0,0 +1,31 @@ +[ + { "key": "authorization", "sensitive": true }, + { "key": "Authorization", "sensitive": true }, + { "key": "AUTHORIZATION", "sensitive": true }, + { "key": "token", "sensitive": true }, + { "key": "TOKEN", "sensitive": true }, + { "key": "anthropic_token", "sensitive": true }, + { "key": "ANTHROPIC_TOKEN", "sensitive": true }, + { "key": "anthropic-token", "sensitive": true }, + { "key": "ANTHROPIC_AUTH_TOKEN", "sensitive": true }, + { "key": "ANTHROPIC_API_KEY", "sensitive": true }, + { "key": "openai_api_key", "sensitive": true }, + { "key": "client_secret", "sensitive": true }, + { "key": "SECRET_VALUE", "sensitive": true }, + { "key": "user_password", "sensitive": true }, + { "key": "Password", "sensitive": true }, + { "key": "x_api_key", "sensitive": true }, + { "key": "X-API-KEY", "sensitive": true }, + { "key": "x-api-key", "sensitive": true }, + { "key": "apikey", "sensitive": true }, + { "key": "ApiKey", "sensitive": true }, + { "key": "model", "sensitive": false }, + { "key": "env", "sensitive": false }, + { "key": "permissions", "sensitive": false }, + { "key": "auth", "sensitive": false }, + { "key": "tokenizer", "sensitive": false }, + { "key": "mytoken", "sensitive": false }, + { "key": "tokens", "sensitive": false }, + { "key": "api", "sensitive": false }, + { "key": "myapikeything", "sensitive": false } +] diff --git a/src/App.test.tsx b/src/App.test.tsx index c3092d3..432b933 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -582,10 +582,13 @@ describe("App", () => { openUrlMock.mockClear(); revealItemInDirMock.mockClear(); usagePageRenderMock.mockClear(); - // 默认 workspace;deep link drain 必须返回数组,避免 null.length 干扰其它用例 + // 默认 workspace;deep link peek 默认空队列 invokeMock.mockImplementation(async (command) => { - if (command === "drain_pending_profile_import_deep_links") { - return []; + if (command === "peek_pending_profile_import_deep_link") { + return null; + } + if (command === "count_pending_profile_import_deep_links") { + return 0; } if (command === "get_config_workspace") { return WORKSPACE_FIXTURE; @@ -802,6 +805,116 @@ describe("App", () => { }); }); + it("defers deep-link drain while a dirty profile editor is open and drains after exit", async () => { + enableTauriEvents(); + const workspaceWithProfile: ConfigWorkspace = { + ...WORKSPACE_FIXTURE, + builtinProviders: [ + { + id: "builtin:openrouter", + name: "OpenRouter", + localizedName: { zh: "开放路由", en: "OpenRouter" }, + description: "OpenRouter", + modelSuggestions: ["claude-sonnet-4-6"], + env: {}, + }, + ], + profiles: [ + { + id: "user-openrouter", + name: "OpenRouter User", + description: "默认用户配置", + providerId: "builtin:openrouter", + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: "token", + ANTHROPIC_MODEL: "claude-sonnet-4-6", + }, + }, + createdAt: "2026-04-18T12:00:00Z", + updatedAt: "2026-04-18T12:00:00Z", + }, + ], + }; + let peekCount = 0; + let pendingHead: string | null = null; + invokeMock.mockImplementation(async (command, args) => { + if (command === "get_config_workspace") { + return workspaceWithProfile; + } + if (command === "peek_pending_profile_import_deep_link") { + peekCount += 1; + return pendingHead; + } + if (command === "count_pending_profile_import_deep_links") { + return pendingHead ? 1 : 0; + } + if (command === "ack_profile_import_deep_link") { + if (pendingHead && (args as { url?: string })?.url === pendingHead) { + pendingHead = null; + return true; + } + return false; + } + if (command === "resolve_profile_import_deep_link") { + return { + name: "", + description: "", + settingsJson: '{\n "model": "claude-sonnet-4-6"\n}', + containsSecrets: false, + source: "payload", + }; + } + return null; + }); + + renderApp(); + await waitFor(() => { + expect(screen.queryByText("加载中...")).not.toBeInTheDocument(); + }); + const peekAfterLoad = peekCount; + + // 打开配置编辑器并制造 dirty + fireEvent.click( + await screen.findByRole("button", { name: "OpenRouter User" }, { timeout: 5000 }), + ); + const nameInput = await screen.findByDisplayValue("OpenRouter User", {}, { timeout: 5000 }); + fireEvent.change(nameInput, { target: { value: "OpenRouter User Draft" } }); + + // 后端积压一条 deep link;脏编辑器时应 requestExit 且不因唤醒而打开导入(peek 仅在 force 时用于切页) + pendingHead = "code-manager://profiles/import?payload=abc"; + await act(async () => { + await emitTauriEvent("profile-import-deep-link", undefined); + }); + await act(async () => { + await Promise.resolve(); + }); + // 脏态:只 requestExit,不 peek(wake 在 guard 处短路) + expect(peekCount).toBe(peekAfterLoad); + expect(screen.getByRole("heading", { name: "存在未保存的更改" })).toBeInTheDocument(); + + // 继续编辑:仍不 peek 唤醒 + fireEvent.click(screen.getByRole("button", { name: "继续编辑" })); + await waitFor(() => { + expect(screen.queryByRole("heading", { name: "存在未保存的更改" })).not.toBeInTheDocument(); + }); + expect(peekCount).toBe(peekAfterLoad); + + // 关闭抽屉:dirty 时再弹 exit-guard,不保存退出后解除 guard → force wake → peek + fireEvent.click(screen.getByRole("button", { name: "关闭" })); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "存在未保存的更改" })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("button", { name: "不保存退出" })); + + await waitFor(() => { + expect(peekCount).toBeGreaterThan(peekAfterLoad); + }); + expect( + await screen.findByRole("dialog", { name: "导入配置" }, { timeout: 5000 }), + ).toBeInTheDocument(); + }, 15_000); + it("reloads the config workspace when user settings changes", async () => { enableTauriEvents(); invokeMock.mockImplementation(async (command) => { @@ -813,10 +926,18 @@ describe("App", () => { renderApp(); + // 首屏 load 次数受 language 同步是否重建 loadWorkspace 影响(1 或 2),只要求至少一次后稳定再测增量 await waitFor(() => { expect( - invokeMock.mock.calls.filter(([command]) => command === "get_config_workspace"), - ).toHaveLength(2); + invokeMock.mock.calls.filter(([command]) => command === "get_config_workspace").length, + ).toBeGreaterThanOrEqual(1); + }); + await waitFor(() => { + expect(screen.queryByText("加载中...")).not.toBeInTheDocument(); + }); + // 等可能的二次 load 落定 + await act(async () => { + await Promise.resolve(); }); const workspaceLoadCount = invokeMock.mock.calls.filter( ([command]) => command === "get_config_workspace", diff --git a/src/App.tsx b/src/App.tsx index 07f9614..53a0143 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,9 @@ const EMPTY_WORKSPACE: ConfigWorkspace = { floatingWidgetOpacity: 92, waitingSoundEnabled: false, waitingSound: "glass", + // 与 Rust AppPreferences::default 对齐;缺省时保存可能误清用户防休眠偏好 + sleepPrevention: "off", + keepDisplayAwake: false, }, builtinProviders: [], profiles: [], @@ -91,17 +94,17 @@ function App() { project: string; requestId: number; } | null>(null); - const [deepLinkImportRequest, setDeepLinkImportRequest] = useState<{ - urls: string[]; - requestId: number; - } | null>(null); + // 递增令牌:仅唤醒 ProfilesPage 去 peek 后端队列,URL 权威源在 Rust pending + const [deepLinkWakeToken, setDeepLinkWakeToken] = useState(0); const previousContentTabRef = useRef("configs"); const editorExitGuardRef = useRef(null); const historyProjectRequestIdRef = useRef(0); const usageProjectRequestIdRef = useRef(0); - const deepLinkImportRequestIdRef = useRef(0); const workspaceRequestIdRef = useRef(0); - + const activateTabRef = useRef<(tab: TabType) => void>(() => {}); + const wakeProfileImportDeepLinksRef = useRef<(options?: { force?: boolean }) => Promise>( + async () => {}, + ); const loadWorkspace = useCallback(async () => { if (!isTauri()) { setWorkspace(EMPTY_WORKSPACE); @@ -163,8 +166,46 @@ function App() { } }); + // Toast/i18n 走 ref,避免语言切换重建 drain 回调并误触发冷启动 effect + const showToastRef = useRef(showToast); + const tRef = useRef(t); + showToastRef.current = showToast; + tRef.current = t; + + // 配置导入 deep link:peek 后端队列是否有待处理项,有则切到配置页并递增 wake token。 + // URL 始终留在后端直到 ProfilesPage ack;切页不丢链。 + // force=true 跳过 exit-guard(用户已确认离开,或 guard 刚解除后的重试)。 + // 脏编辑器时只 requestExit、**不** 切页唤醒;URL 仍在后端,guard 解除时再试。 + const wakeProfileImportDeepLinks = useCallback(async (options?: { force?: boolean }) => { + if (!isTauri()) return; + if (!options?.force && editorExitGuardRef.current) { + editorExitGuardRef.current.requestExit(() => { + void wakeProfileImportDeepLinksRef.current({ force: true }); + }); + return; + } + try { + const head = await ipc.peekPendingProfileImportDeepLink(); + if (!head) return; + setDeepLinkWakeToken((token) => token + 1); + activateTabRef.current("configs"); + } catch (error) { + showOperationError( + showToastRef.current, + tRef.current("profiles.import.deepLink.toast.resolveError"), + error, + ); + } + }, []); + wakeProfileImportDeepLinksRef.current = wakeProfileImportDeepLinks; + const setEditorExitGuard = useCallback((guard: EditorExitGuard | null) => { + const hadGuard = editorExitGuardRef.current != null; editorExitGuardRef.current = guard; + // 编辑器关闭/解除保护后重试 pending deep link(覆盖「继续编辑」未唤醒的场景) + if (hadGuard && guard == null) { + void wakeProfileImportDeepLinksRef.current({ force: true }); + } }, []); const runWithEditorExitGuard = useCallback((action: () => void) => { @@ -176,7 +217,6 @@ function App() { action(); }, []); - const activateTab = useCallback((nextTab: TabType) => { if (nextTab === "claudeOverview") { setHasVisitedClaudeOverview(true); @@ -186,6 +226,7 @@ function App() { setActiveTab(nextTab); setIsDetailDrawerOpen(false); }, []); + activateTabRef.current = activateTab; useEffect(() => { if (activeTab !== "history") { @@ -194,6 +235,7 @@ function App() { if (activeTab !== "usage") { setUsageProjectRequest(null); } + // deep link 权威源在后端 pending:离开 configs 不 ack、不清队列 }, [activeTab]); useTauriEvent("navigate-to-tab", (tab) => { @@ -281,44 +323,14 @@ function App() { [activateTab, runWithEditorExitGuard], ); - // Toast/i18n 走 ref,避免语言切换重建 drain 回调并误触发冷启动 effect - const showToastRef = useRef(showToast); - const tRef = useRef(t); - showToastRef.current = showToast; - tRef.current = t; - - // 配置导入 deep link:drain 后端 pending 队列,切到配置页交给 ProfilesPage 排队预览 - const drainProfileImportDeepLinks = useCallback(async () => { - if (!isTauri()) return; - try { - const urls = await ipc.drainPendingProfileImportDeepLinks(); - if (!urls?.length) return; - runWithEditorExitGuard(() => { - deepLinkImportRequestIdRef.current += 1; - setDeepLinkImportRequest({ - urls, - requestId: deepLinkImportRequestIdRef.current, - }); - activateTab("configs"); - }); - } catch (error) { - showOperationError( - showToastRef.current, - tRef.current("profiles.import.deepLink.toast.resolveError"), - error, - ); - } - }, [activateTab, runWithEditorExitGuard]); - useEffect(() => { if (loading) return; - void drainProfileImportDeepLinks(); - }, [loading, drainProfileImportDeepLinks]); + void wakeProfileImportDeepLinks(); + }, [loading, wakeProfileImportDeepLinks]); useTauriEvent("profile-import-deep-link", () => { - void drainProfileImportDeepLinks(); + void wakeProfileImportDeepLinks(); }); - if (loading) { return ( @@ -383,7 +395,7 @@ function App() { workspace={workspace} onWorkspaceChange={loadWorkspace} onEditorExitGuardChange={setEditorExitGuard} - deepLinkImportRequest={deepLinkImportRequest} + deepLinkWakeToken={deepLinkWakeToken} /> ) : (
typedError(__TAURI_INVOKE("resolve_profile_import_deep_link", { url })), /** 从配置导出内容生成内嵌载荷 deep link(默认路径 A)。 */ buildProfileImportDeepLink: (id: string, includeSecrets: boolean) => typedError(__TAURI_INVOKE("build_profile_import_deep_link", { id, includeSecrets })), - /** 取出冷启动积压的配置导入 deep link(取出后清空)。 */ - drainPendingProfileImportDeepLinks: () => typedError(__TAURI_INVOKE("drain_pending_profile_import_deep_links")), + /** 窥视队头 URL,不移除。无待处理时返回 `None`。 */ + peekPendingProfileImportDeepLink: () => typedError(__TAURI_INVOKE("peek_pending_profile_import_deep_link")), + /** 待处理条数(含当前队头)。用于「还有待处理的导入链接」提示。 */ + countPendingProfileImportDeepLinks: () => typedError(__TAURI_INVOKE("count_pending_profile_import_deep_links")), + /** + * 确认结束当前队头:仅当队头等于 `url` 时移除。 + * 导入成功、用户取消、解析失败后调用;切页不应调用。 + */ + ackProfileImportDeepLink: (url: string) => typedError(__TAURI_INVOKE("ack_profile_import_deep_link", { url })), testProfileModel: (data: ModelTestInput) => typedError(__TAURI_INVOKE("test_profile_model", { data })), setAppPreferences: (data: AppPreferencesInput) => typedError(__TAURI_INVOKE("set_app_preferences", { data })), /** 切换浮窗显隐(幂等):显示时不存在则创建,隐藏时隐藏而非关闭以保留位置与状态。 */ diff --git a/src/components/ProfilesPage.tsx b/src/components/ProfilesPage.tsx index f7cf350..1196c0c 100644 --- a/src/components/ProfilesPage.tsx +++ b/src/components/ProfilesPage.tsx @@ -24,6 +24,7 @@ import { useRef, useState, } from "react"; +import { settingsJsonContainsSecrets } from "@/lib/settings-secrets"; import { getUserFacingErrorReason, showOperationError } from "@/lib/user-facing-error"; import { cn } from "@/lib/utils"; import { useToast } from "../hooks/useToast"; @@ -37,6 +38,7 @@ import type { UnmanagedUserSettings, UnmanagedUserSettingsImportStatus, } from "../types"; +import { isTauri } from "../types"; import ConfigPreview from "./ConfigPreview"; import ConfirmAlertDialog from "./ConfirmAlertDialog"; import { @@ -103,8 +105,11 @@ interface ProfilesPageProps { workspace: ConfigWorkspace; onWorkspaceChange: () => Promise; onEditorExitGuardChange?: (guard: EditorExitGuard | null) => void; - /** App 从 deep link pending 队列 drain 后下发的导入请求 */ - deepLinkImportRequest?: { urls: string[]; requestId: number } | null; + /** + * App 在确认后端仍有 pending deep link 后递增的唤醒令牌。 + * ProfilesPage 据此 peek 队头;URL 权威源在 Rust,ack 前切页不丢。 + */ + deepLinkWakeToken?: number; } /** 导入对话框来源:本地文件或已解析的 deep link */ @@ -268,7 +273,7 @@ function ProfilesPage({ workspace, onWorkspaceChange, onEditorExitGuardChange, - deepLinkImportRequest = null, + deepLinkWakeToken = 0, }: ProfilesPageProps) { const { language, t } = useI18n(); const { showToast } = useToast(); @@ -306,11 +311,12 @@ function ProfilesPage({ const [importDescription, setImportDescription] = useState(""); const [isImporting, setIsImporting] = useState(false); const [importSecretsAcknowledged, setImportSecretsAcknowledged] = useState(false); - const deepLinkQueueRef = useRef([]); const importDialogOpenRef = useRef(false); const deepLinkResolveBusyRef = useRef(false); - // 只按 requestId 入队一次,避免语言切换导致 t/pump 引用变化时重复 push - const lastDeepLinkRequestIdRef = useRef(null); + // 当前预览对应的队头 URL;ack 前保留在后端,切页 remount 可再次 peek + const deepLinkInflightUrlRef = useRef(null); + // 同一 wake token 只触发一次 pump,避免父重渲染重复 resolve + const lastDeepLinkWakeTokenRef = useRef(null); const showToastRef = useRef(showToast); const tRef = useRef(t); showToastRef.current = showToast; @@ -894,7 +900,10 @@ function ProfilesPage({ setImportSecretsAcknowledged(false); } - function closeImportDialog() { + function closeImportDialog(options?: { ackDeepLink?: boolean }) { + const shouldAckDeepLink = + options?.ackDeepLink !== false && importDialogSource?.kind === "deepLink"; + const inflightUrl = deepLinkInflightUrlRef.current; importDialogOpenRef.current = false; setImportDialogSource(null); setImportPreview(""); @@ -903,54 +912,92 @@ function ProfilesPage({ setImportDescription(""); setIsImporting(false); setImportSecretsAcknowledged(false); - // 关闭当前预览后继续处理排队中的 deep link - void pumpDeepLinkQueue(); + // 用户取消或关闭预览:ack 当前队头后再 peek 下一条;切页不走此路径 + void (async () => { + if (shouldAckDeepLink && inflightUrl) { + try { + await ipc.ackProfileImportDeepLink(inflightUrl); + } catch (err) { + showOperationError( + showToastRef.current, + tRef.current("profiles.import.deepLink.toast.resolveError"), + err, + ); + } + deepLinkInflightUrlRef.current = null; + } + void pumpDeepLinkQueue(); + })(); } + // 文件与 deep link 共用:预览含密钥时须勾选确认 + const importContainsSecrets = + importDialogSource?.kind === "deepLink" + ? importDialogSource.containsSecrets + : importDialogSource?.kind === "file" + ? settingsJsonContainsSecrets(importPreview) + : false; + // 确认导入:校验通过后创建新配置(不自动绑定/激活) async function handleConfirmImport() { if (!importDialogSource || importPreviewError) return; - if ( - importDialogSource.kind === "deepLink" && - importDialogSource.containsSecrets && - !importSecretsAcknowledged - ) { + if (importContainsSecrets && !importSecretsAcknowledged) { return; } setIsImporting(true); + const resolvedName = importName.trim() || t("profiles.import.defaultName"); + const isDeepLink = importDialogSource.kind === "deepLink"; + const inflightUrl = deepLinkInflightUrlRef.current; try { if (importDialogSource.kind === "file") { await ipc.importProfileFromFile( importDialogSource.sourcePath, - importName, + resolvedName, importDescription, ); } else { await ipc.importProfileFromSettingsJson( importDialogSource.settingsJson, - importName, + resolvedName, importDescription, ); } + // 导入成功后再 ack,避免失败时误丢队头 + if (isDeepLink && inflightUrl) { + try { + await ipc.ackProfileImportDeepLink(inflightUrl); + } catch (ackErr) { + showOperationError(showToast, t("profiles.import.deepLink.toast.resolveError"), ackErr); + } + deepLinkInflightUrlRef.current = null; + } await onWorkspaceChange(); showToast(t("profiles.import.toast.imported")); - closeImportDialog(); + // 已 ack,关闭时勿重复 ack + closeImportDialog({ ackDeepLink: false }); } catch (err) { showOperationError(showToast, t("profiles.import.toast.importError"), err); setIsImporting(false); } } - // 解析并弹出下一条 deep link;失败则 Toast 后自动继续。 - // 回调保持稳定:Toast/i18n 走 ref,避免语言切换重建导致入队 effect 重跑。 + // 窥视后端队头并弹出预览;失败则 ack 丢弃后继续下一条。 + // Toast/i18n 走 ref,避免语言切换重建 pump 引用导致 effect 重跑。 const pumpDeepLinkQueue = useCallback(async () => { if (importDialogOpenRef.current || deepLinkResolveBusyRef.current) return; - const nextUrl = deepLinkQueueRef.current.shift(); - if (!nextUrl) return; + if (!isTauri()) return; deepLinkResolveBusyRef.current = true; setIsImportPreviewLoading(true); setImportPreviewError(null); try { + const nextUrl = await ipc.peekPendingProfileImportDeepLink(); + if (!nextUrl) { + deepLinkResolveBusyRef.current = false; + setIsImportPreviewLoading(false); + deepLinkInflightUrlRef.current = null; + return; + } + deepLinkInflightUrlRef.current = nextUrl; const resolved = await ipc.resolveProfileImportDeepLink(nextUrl); importDialogOpenRef.current = true; setImportDialogSource({ @@ -959,36 +1006,54 @@ function ProfilesPage({ containsSecrets: resolved.containsSecrets, source: resolved.source, }); - setImportName(resolved.name); + // 后端缺省 name 为空;用 i18n 默认名预填,避免硬编码英文 + setImportName(resolved.name.trim() || tRef.current("profiles.import.defaultName")); setImportDescription(resolved.description); setImportPreview(resolved.settingsJson); setImportSecretsAcknowledged(false); - if (deepLinkQueueRef.current.length > 0) { - showToastRef.current(tRef.current("profiles.import.deepLink.toast.queueHint")); + try { + const pendingCount = await ipc.countPendingProfileImportDeepLinks(); + if (pendingCount > 1) { + showToastRef.current(tRef.current("profiles.import.deepLink.toast.queueHint")); + } + } catch { + // 队列提示失败不阻断预览 } + deepLinkResolveBusyRef.current = false; + setIsImportPreviewLoading(false); } catch (err) { showOperationError( showToastRef.current, tRef.current("profiles.import.deepLink.toast.resolveError"), err, ); + const failedUrl = deepLinkInflightUrlRef.current; + if (failedUrl) { + try { + await ipc.ackProfileImportDeepLink(failedUrl); + } catch { + // 解析已失败;ack 再失败只记日志路径由后端 warn + } + deepLinkInflightUrlRef.current = null; + } deepLinkResolveBusyRef.current = false; setIsImportPreviewLoading(false); void pumpDeepLinkQueue(); - return; } - deepLinkResolveBusyRef.current = false; - setIsImportPreviewLoading(false); }, []); - // 仅在新的 requestId 时入队;同一 request 被语言切换/父组件重渲染时不得再 push + // 新 wake token 或首次挂载:从后端 peek 恢复/继续处理(切页 remount 也走这里) useEffect(() => { - if (!deepLinkImportRequest) return; - if (lastDeepLinkRequestIdRef.current === deepLinkImportRequest.requestId) return; - lastDeepLinkRequestIdRef.current = deepLinkImportRequest.requestId; - deepLinkQueueRef.current.push(...deepLinkImportRequest.urls); + if (deepLinkWakeToken <= 0 && lastDeepLinkWakeTokenRef.current === null) { + // 冷启动:即便 token 仍为 0 也尝试 peek(App 可能尚未 bump 或已在 configs) + lastDeepLinkWakeTokenRef.current = 0; + void pumpDeepLinkQueue(); + return; + } + if (lastDeepLinkWakeTokenRef.current === deepLinkWakeToken) return; + lastDeepLinkWakeTokenRef.current = deepLinkWakeToken; void pumpDeepLinkQueue(); - }, [deepLinkImportRequest, pumpDeepLinkQueue]); + }, [deepLinkWakeToken, pumpDeepLinkQueue]); // 拉取导出预览:目标配置或「包含密钥」开关变化时重新加载,保证预览所见即所得 useEffect(() => { @@ -2313,7 +2378,7 @@ function ProfilesPage({ : t("profiles.import.dialogDescription")} - {importDialogSource?.kind === "deepLink" && importDialogSource.containsSecrets ? ( + {importContainsSecrets ? (

{t("profiles.import.deepLink.secretsWarning")} @@ -2354,7 +2419,7 @@ function ProfilesPage({

-