From dc6daae4268f28c4f6d5baf0ac7a5c36278c5424 Mon Sep 17 00:00:00 2001 From: maguowei Date: Tue, 14 Jul 2026 15:23:55 +0800 Subject: [PATCH 1/6] =?UTF-8?q?docs(context):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F=E3=80=81=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E3=80=81Skills=20=E4=B8=8E=E5=8E=86=E5=8F=B2=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E9=A2=86=E5=9F=9F=E6=9C=AF=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) 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 From 9c53287f0771867eda08c93ba2e8f64adfee8ca9 Mon Sep 17 00:00:00 2001 From: maguowei Date: Tue, 14 Jul 2026 17:59:20 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(deep-link):=20=E5=85=A5=E9=98=9F?= =?UTF-8?q?=E5=90=8E=E6=8C=89=20requestId=20=E6=B8=85=E7=A9=BA=E9=81=BF?= =?UTF-8?q?=E5=85=8D=20remount=20=E9=87=8D=E6=94=BE=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfilesPage 接管 deep link urls 后通知 App 条件清空 request; 离开 configs 时同步丢弃未消费请求,防止 lazy remount 再弹已放弃预览。 --- src/App.tsx | 12 ++ src/components/ProfilesPage.tsx | 19 +- .../__tests__/ProfilesPage.test.tsx | 175 ++++++++++++++++++ 3 files changed, 203 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 07f9614..a249f4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -194,6 +194,11 @@ function App() { if (activeTab !== "usage") { setUsageProjectRequest(null); } + // 与 history/usage 一致:离开 configs 时丢弃未消费的 deep link request, + // 防止 lazy ProfilesPage 在 effect 入队前卸载后残留 state 再次投递 + if (activeTab !== "configs") { + setDeepLinkImportRequest(null); + } }, [activeTab]); useTauriEvent("navigate-to-tab", (tab) => { @@ -310,6 +315,12 @@ function App() { } }, [activateTab, runWithEditorExitGuard]); + // ProfilesPage 接管 urls 入队后按 requestId 清空,避免残留 state 在 remount 时重放; + // 仅匹配当前 id 时清空,防止旧请求的 clear 冲掉并发 drain 的新 request + const handleDeepLinkImportConsumed = useCallback((requestId: number) => { + setDeepLinkImportRequest((current) => (current?.requestId === requestId ? null : current)); + }, []); + useEffect(() => { if (loading) return; void drainProfileImportDeepLinks(); @@ -384,6 +395,7 @@ function App() { onWorkspaceChange={loadWorkspace} onEditorExitGuardChange={setEditorExitGuard} deepLinkImportRequest={deepLinkImportRequest} + onDeepLinkImportConsumed={handleDeepLinkImportConsumed} /> ) : (
void; /** App 从 deep link pending 队列 drain 后下发的导入请求 */ deepLinkImportRequest?: { urls: string[]; requestId: number } | null; + /** + * ProfilesPage 接管 urls 入队后通知 App 清空对应 request。 + * 必须带 requestId,App 侧仅在仍匹配时清空,避免冲掉并发 drain 的新请求。 + */ + onDeepLinkImportConsumed?: (requestId: number) => void; } /** 导入对话框来源:本地文件或已解析的 deep link */ @@ -269,6 +274,7 @@ function ProfilesPage({ onWorkspaceChange, onEditorExitGuardChange, deepLinkImportRequest = null, + onDeepLinkImportConsumed, }: ProfilesPageProps) { const { language, t } = useI18n(); const { showToast } = useToast(); @@ -311,6 +317,9 @@ function ProfilesPage({ const deepLinkResolveBusyRef = useRef(false); // 只按 requestId 入队一次,避免语言切换导致 t/pump 引用变化时重复 push const lastDeepLinkRequestIdRef = useRef(null); + // 入队后回调 App 清空 request;走 ref 避免 effect 因回调引用变化重跑 + const onDeepLinkImportConsumedRef = useRef(onDeepLinkImportConsumed); + onDeepLinkImportConsumedRef.current = onDeepLinkImportConsumed; const showToastRef = useRef(showToast); const tRef = useRef(t); showToastRef.current = showToast; @@ -981,12 +990,16 @@ function ProfilesPage({ setIsImportPreviewLoading(false); }, []); - // 仅在新的 requestId 时入队;同一 request 被语言切换/父组件重渲染时不得再 push + // 仅在新的 requestId 时入队;同一 request 被语言切换/父组件重渲染时不得再 push。 + // 入队后立即按 requestId 通知 App 清空:urls 已转入本地 queue,App 若残留该 state, + // 会在 ProfilesPage 条件渲染 remount 时被重新投递,导致用户放弃后切回 configs 又弹一次。 useEffect(() => { if (!deepLinkImportRequest) return; if (lastDeepLinkRequestIdRef.current === deepLinkImportRequest.requestId) return; - lastDeepLinkRequestIdRef.current = deepLinkImportRequest.requestId; - deepLinkQueueRef.current.push(...deepLinkImportRequest.urls); + const { requestId, urls } = deepLinkImportRequest; + lastDeepLinkRequestIdRef.current = requestId; + deepLinkQueueRef.current.push(...urls); + onDeepLinkImportConsumedRef.current?.(requestId); void pumpDeepLinkQueue(); }, [deepLinkImportRequest, pumpDeepLinkQueue]); diff --git a/src/components/__tests__/ProfilesPage.test.tsx b/src/components/__tests__/ProfilesPage.test.tsx index 2b35bc5..a1e4878 100644 --- a/src/components/__tests__/ProfilesPage.test.tsx +++ b/src/components/__tests__/ProfilesPage.test.tsx @@ -2305,6 +2305,181 @@ describe("ProfilesPage", () => { ).toHaveLength(1); }); + it("calls onDeepLinkImportConsumed with requestId after queuing deep link urls", async () => { + const onWorkspaceChange = vi.fn(async () => {}); + const onDeepLinkImportConsumed = vi.fn(); + invokeMock.mockImplementation(async (command: string) => { + if (command === "resolve_profile_import_deep_link") { + return { + name: "FromLink", + description: "", + settingsJson: '{\n "model": "claude-sonnet-4-6"\n}', + containsSecrets: false, + source: "payload", + }; + } + return null; + }); + + render( + + + + + , + ); + + // 入队后立即带 requestId 通知 App 清空,避免残留 state 跨 remount 重放 + await waitFor(() => { + expect(onDeepLinkImportConsumed).toHaveBeenCalledTimes(1); + }); + expect(onDeepLinkImportConsumed).toHaveBeenCalledWith(7); + expect( + invokeMock.mock.calls.filter(([command]) => command === "resolve_profile_import_deep_link"), + ).toHaveLength(1); + }); + + it("does not re-queue deep link urls after App clears the request on remount", async () => { + const onWorkspaceChange = vi.fn(async () => {}); + // 模拟 App 侧按 requestId 条件清空 + let parentRequest: { urls: string[]; requestId: number } | null = { + urls: ["code-manager://profiles/import?payload=abc"], + requestId: 42, + }; + const onDeepLinkImportConsumed = vi.fn((requestId: number) => { + if (parentRequest?.requestId === requestId) { + parentRequest = null; + } + }); + invokeMock.mockImplementation(async (command: string) => { + if (command === "resolve_profile_import_deep_link") { + return { + name: "FromLink", + description: "", + settingsJson: '{\n "model": "claude-sonnet-4-6"\n}', + containsSecrets: false, + source: "payload", + }; + } + return null; + }); + + // 首次挂载:入队后回调 App 按 requestId 清空 + const { unmount } = render( + + + + + , + ); + + await waitFor(() => { + expect(onDeepLinkImportConsumed).toHaveBeenCalledWith(42); + }); + expect(parentRequest).toBeNull(); + + // 模拟用户切走 configs tab:ProfilesPage 卸载 + unmount(); + + // 修复后 parentRequest 已清空,切回 configs 重新挂载不再下发旧 request + render( + + + + + , + ); + + await act(async () => { + await Promise.resolve(); + }); + + // remount 不得再次 resolve(原 bug 会重放已放弃的导入) + expect( + invokeMock.mock.calls.filter(([command]) => command === "resolve_profile_import_deep_link"), + ).toHaveLength(1); + expect(onDeepLinkImportConsumed).toHaveBeenCalledTimes(1); + }); + + it("re-queues residual non-null deepLinkImportRequest on remount when App did not clear", async () => { + const onWorkspaceChange = vi.fn(async () => {}); + invokeMock.mockImplementation(async (command: string) => { + if (command === "resolve_profile_import_deep_link") { + return { + name: "FromLink", + description: "", + settingsJson: '{\n "model": "claude-sonnet-4-6"\n}', + containsSecrets: false, + source: "payload", + }; + } + return null; + }); + + const residualRequest = { + urls: ["code-manager://profiles/import?payload=abc"], + requestId: 42, + }; + + // 无 onDeepLinkImportConsumed:模拟 App 残留非 null request + const { unmount } = render( + + + + + , + ); + + await waitFor(() => { + expect( + invokeMock.mock.calls.filter(([command]) => command === "resolve_profile_import_deep_link"), + ).toHaveLength(1); + }); + + unmount(); + + // remount 时 lastDeepLinkRequestIdRef 重置,残留同一 request 会再次入队(说明为何必须清空) + render( + + + + + , + ); + + await waitFor(() => { + expect( + invokeMock.mock.calls.filter(([command]) => command === "resolve_profile_import_deep_link"), + ).toHaveLength(2); + }); + }); + it("imports a deep link payload after secrets acknowledgement", async () => { const onWorkspaceChange = vi.fn(async () => {}); invokeMock.mockImplementation(async (command: string) => { From 2a344a5c4fd441400b48a538670c187a01d49069 Mon Sep 17 00:00:00 2001 From: maguowei Date: Wed, 15 Jul 2026 09:39:08 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E5=8F=91=E7=89=88=E5=89=8D=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20deep=20link=20=E9=98=9F=E5=88=97=E3=80=81=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=20i18n=20=E4=B8=8E=E9=A1=B9=E7=9B=AE=E8=BD=AF?= =?UTF-8?q?=E9=93=BE=E5=8F=AA=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 脏编辑器时延后 drain pending deep link,取消不丢链,guard 解除后重试 - 稳定 App 工作区 reload 断言,避免依赖 1/2 次 load 竞态 - 导入默认名走 i18n;文件导入与 deep link 统一密钥确认门禁 - 项目 .claude 根为软链时允许只读总览/预览,写仍拒绝 - 命中率多系列按纯 input tokens 排序;图表 10px 白名单与 path 规则对齐 ADR Closes #18 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 --- .claude/rules/tauri-backend.md | 5 +- src-tauri/src/config.rs | 6 +- src-tauri/src/deep_link.rs | 15 ++- src-tauri/src/project.rs | 72 ++++++++--- src/App.test.tsx | 114 +++++++++++++++++- src/App.tsx | 78 +++++++----- src/components/ProfilesPage.tsx | 29 +++-- src/components/UsagePage.tsx | 12 +- .../__tests__/ProfilesPage.test.tsx | 87 +++++++++++++ .../__tests__/ui-system-contract.test.ts | 4 + src/i18n.ts | 2 + src/lib/__tests__/settings-secrets.test.ts | 34 ++++++ src/lib/settings-secrets.ts | 48 ++++++++ 13 files changed, 433 insertions(+), 73 deletions(-) create mode 100644 src/lib/__tests__/settings-secrets.test.ts create mode 100644 src/lib/settings-secrets.ts diff --git a/.claude/rules/tauri-backend.md b/.claude/rules/tauri-backend.md index 9725664..06981da 100644 --- a/.claude/rules/tauri-backend.md +++ b/.claude/rules/tauri-backend.md @@ -84,8 +84,9 @@ const workspace = await ipc.getConfigWorkspace(); ## 后端边界 - 后端继续负责配置合并、路径校验、目录遍历安全、真实落盘和日志脱敏。 -- 路径相关 command 必须防止符号链接、绝对路径和 `..` 路径逃逸。 -- `claude_directory` 和 `project` 的文件树/预览 command 只能暴露受控目录内部内容,不能让前端绕过后端路径边界。 +- 路径相关 command 必须防止绝对路径和 `..` 路径逃逸。 +- **只读跟随软链(ADR 0002)**:`claude_directory` 与项目级 `.claude` 的**扫描 / 预览**允许跟随符号链接,目标可在受控 root 外;相对路径仍禁止 `..` / 绝对段。用户级与项目级(含项目 `.claude` 根目录本身是软链)共用该只读契约。 +- **写操作仍拒绝软链路径**:新建 / 重命名 / 删除等写路径一旦经过软链组件必须拒绝;项目侧创建设置文件时若 `.claude` 根是软链也拒绝。 - 日志脱敏字段清单与日志格式规范见 `.claude/rules/projects-tray-diagnostics.md` 的「日志与诊断」一节,不要在两处维护副本。 ## 用量 runtime 与 SQLite diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index a37c33e..731cb48 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() }, diff --git a/src-tauri/src/deep_link.rs b/src-tauri/src/deep_link.rs index 446f46c..019abb2 100644 --- a/src-tauri/src/deep_link.rs +++ b/src-tauri/src/deep_link.rs @@ -253,10 +253,7 @@ fn parse_profile_import_deep_link(raw: &str) -> Result 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/App.test.tsx b/src/App.test.tsx index c3092d3..e0992f2 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -802,6 +802,108 @@ 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 drainCount = 0; + let pendingUrls: string[] = []; + invokeMock.mockImplementation(async (command) => { + if (command === "get_config_workspace") { + return workspaceWithProfile; + } + if (command === "drain_pending_profile_import_deep_links") { + drainCount += 1; + const urls = pendingUrls; + pendingUrls = []; + return urls; + } + 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 drainAfterLoad = drainCount; + + // 打开配置编辑器并制造 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 且不 drain IPC + pendingUrls = ["code-manager://profiles/import?payload=abc"]; + await act(async () => { + await emitTauriEvent("profile-import-deep-link", undefined); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(drainCount).toBe(drainAfterLoad); + expect(screen.getByRole("heading", { name: "存在未保存的更改" })).toBeInTheDocument(); + + // 继续编辑:仍不 drain + fireEvent.click(screen.getByRole("button", { name: "继续编辑" })); + await waitFor(() => { + expect(screen.queryByRole("heading", { name: "存在未保存的更改" })).not.toBeInTheDocument(); + }); + expect(drainCount).toBe(drainAfterLoad); + + // 关闭抽屉:dirty 时再弹 exit-guard,不保存退出后解除 guard → force drain + // pendingUrls 在首次事件时未 drain,仍保留在 mock 队列中 + fireEvent.click(screen.getByRole("button", { name: "关闭" })); + await waitFor(() => { + expect(screen.getByRole("heading", { name: "存在未保存的更改" })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("button", { name: "不保存退出" })); + + await waitFor(() => { + expect(drainCount).toBeGreaterThan(drainAfterLoad); + }); + 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 +915,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 a249f4c..ceb0890 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -101,7 +101,10 @@ function App() { const usageProjectRequestIdRef = useRef(0); const deepLinkImportRequestIdRef = useRef(0); const workspaceRequestIdRef = useRef(0); - + const activateTabRef = useRef<(tab: TabType) => void>(() => {}); + const drainProfileImportDeepLinksRef = useRef<(options?: { force?: boolean }) => Promise>( + async () => {}, + ); const loadWorkspace = useCallback(async () => { if (!isTauri()) { setWorkspace(EMPTY_WORKSPACE); @@ -163,8 +166,49 @@ function App() { } }); + // Toast/i18n 走 ref,避免语言切换重建 drain 回调并误触发冷启动 effect + const showToastRef = useRef(showToast); + const tRef = useRef(t); + showToastRef.current = showToast; + tRef.current = t; + + // 配置导入 deep link:drain 后端 pending 队列,切到配置页交给 ProfilesPage 排队预览。 + // force=true 跳过 exit-guard(用户已确认离开,或 guard 刚解除后的重试)。 + // 脏编辑器时只 requestExit、**不** drain;取消后 URL 仍在后端队列,guard 解除时再试。 + const drainProfileImportDeepLinks = useCallback(async (options?: { force?: boolean }) => { + if (!isTauri()) return; + if (!options?.force && editorExitGuardRef.current) { + editorExitGuardRef.current.requestExit(() => { + void drainProfileImportDeepLinksRef.current({ force: true }); + }); + return; + } + try { + const urls = await ipc.drainPendingProfileImportDeepLinks(); + if (!urls?.length) return; + deepLinkImportRequestIdRef.current += 1; + setDeepLinkImportRequest({ + urls, + requestId: deepLinkImportRequestIdRef.current, + }); + activateTabRef.current("configs"); + } catch (error) { + showOperationError( + showToastRef.current, + tRef.current("profiles.import.deepLink.toast.resolveError"), + error, + ); + } + }, []); + drainProfileImportDeepLinksRef.current = drainProfileImportDeepLinks; + const setEditorExitGuard = useCallback((guard: EditorExitGuard | null) => { + const hadGuard = editorExitGuardRef.current != null; editorExitGuardRef.current = guard; + // 编辑器关闭/解除保护后重试 pending deep link(覆盖「继续编辑」未 drain 的场景) + if (hadGuard && guard == null) { + void drainProfileImportDeepLinksRef.current({ force: true }); + } }, []); const runWithEditorExitGuard = useCallback((action: () => void) => { @@ -176,7 +220,6 @@ function App() { action(); }, []); - const activateTab = useCallback((nextTab: TabType) => { if (nextTab === "claudeOverview") { setHasVisitedClaudeOverview(true); @@ -186,6 +229,7 @@ function App() { setActiveTab(nextTab); setIsDetailDrawerOpen(false); }, []); + activateTabRef.current = activateTab; useEffect(() => { if (activeTab !== "history") { @@ -286,35 +330,6 @@ 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]); - // ProfilesPage 接管 urls 入队后按 requestId 清空,避免残留 state 在 remount 时重放; // 仅匹配当前 id 时清空,防止旧请求的 clear 冲掉并发 drain 的新 request const handleDeepLinkImportConsumed = useCallback((requestId: number) => { @@ -329,7 +344,6 @@ function App() { useTauriEvent("profile-import-deep-link", () => { void drainProfileImportDeepLinks(); }); - if (loading) { return ( diff --git a/src/components/ProfilesPage.tsx b/src/components/ProfilesPage.tsx index ae66f9f..805dd0d 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"; @@ -916,28 +917,33 @@ function ProfilesPage({ 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"); try { if (importDialogSource.kind === "file") { await ipc.importProfileFromFile( importDialogSource.sourcePath, - importName, + resolvedName, importDescription, ); } else { await ipc.importProfileFromSettingsJson( importDialogSource.settingsJson, - importName, + resolvedName, importDescription, ); } @@ -968,7 +974,8 @@ 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); @@ -2326,7 +2333,7 @@ function ProfilesPage({ : t("profiles.import.dialogDescription")} - {importDialogSource?.kind === "deepLink" && importDialogSource.containsSecrets ? ( + {importContainsSecrets ? (

{t("profiles.import.deepLink.secretsWarning")} @@ -2375,9 +2382,7 @@ function ProfilesPage({ isImporting || !!importPreviewError || isImportPreviewLoading || - (importDialogSource?.kind === "deepLink" && - importDialogSource.containsSecrets && - !importSecretsAcknowledged) + (importContainsSecrets && !importSecretsAcknowledged) } onClick={() => void handleConfirmImport()} > diff --git a/src/components/UsagePage.tsx b/src/components/UsagePage.tsx index 4b64c57..d0bab1f 100644 --- a/src/components/UsagePage.tsx +++ b/src/components/UsagePage.tsx @@ -90,7 +90,9 @@ const CACHE_HIT_RATE_THRESHOLDS = [ ] as const satisfies ReadonlyArray<{ y: number; color: string; labelKey: TranslationKey }>; const TICK_STYLE = { fill: "var(--muted-foreground)", fontSize: 11 }; +// 图表轴/参考线标签白名单:空间受限的 dense chart 允许 10px(见 frontend-ui.md) const TICK_STYLE_SM = { fill: "var(--muted-foreground)", fontSize: 10 }; +const CHART_REFERENCE_LABEL_STYLE = { fontSize: 10 } as const; const CHART_GRID_STROKE = "var(--border)"; const CHART_CURSOR_FILL = "color-mix(in oklch, var(--chart-1) 15%, transparent)"; const CHART_CURSOR_STROKE = "var(--chart-1)"; @@ -426,7 +428,7 @@ function UsagePage({ projectRequest = null, onOpenSessionInHistory }: UsagePageP }); const overallRate = computeCacheHitRatePercent(periodInput, periodCreate, periodRead); - // 图例按输入侧 token 体量降序,主力模型靠前;meta 仍是加权命中率 + // 图例按纯 input tokens 降序,主力模型靠前;meta 仍是加权命中率 const modelSeries: TrendSeriesItem[] = sortedModels .map((model) => { const agg = periodByModel.get(model) ?? { input: 0, create: 0, read: 0 }; @@ -436,7 +438,7 @@ function UsagePage({ projectRequest = null, onOpenSessionInHistory }: UsagePageP originalName: model, color: colorMap.get(model) ?? SERIES_COLORS[0], total: computeCacheHitRatePercent(agg.input, agg.create, agg.read), - volume: agg.input + agg.create + agg.read, + volume: agg.input, }; }) .sort((a, b) => b.volume - a.volume || a.name.localeCompare(b.name)) @@ -1295,7 +1297,8 @@ function UsagePage({ projectRequest = null, onOpenSessionInHistory }: UsagePageP value: t(th.labelKey), position: "insideBottomRight", fill: th.color, - fontSize: 10, + // 与 TICK_STYLE_SM 同属 chart 10px 白名单 + ...CHART_REFERENCE_LABEL_STYLE, }} /> ))} @@ -1374,7 +1377,8 @@ function UsagePage({ projectRequest = null, onOpenSessionInHistory }: UsagePageP value: t(th.labelKey), position: "insideBottomRight", fill: th.color, - fontSize: 10, + // 与 TICK_STYLE_SM 同属 chart 10px 白名单 + ...CHART_REFERENCE_LABEL_STYLE, }} /> ))} diff --git a/src/components/__tests__/ProfilesPage.test.tsx b/src/components/__tests__/ProfilesPage.test.tsx index a1e4878..59d5488 100644 --- a/src/components/__tests__/ProfilesPage.test.tsx +++ b/src/components/__tests__/ProfilesPage.test.tsx @@ -2220,6 +2220,93 @@ describe("ProfilesPage", () => { }); }); + it("requires secrets acknowledgement for file import with auth tokens", async () => { + const onWorkspaceChange = vi.fn(async () => {}); + openDialogMock.mockResolvedValue("/tmp/secret-config.json"); + invokeMock.mockImplementation(async (command: string) => { + if (command === "preview_profile_import") { + return '{\n "env": {\n "ANTHROPIC_AUTH_TOKEN": "sk-test"\n }\n}'; + } + if (command === "import_profile_from_file") { + return { + id: "imported-secret", + name: "secret-config", + description: "", + settings: { env: { ANTHROPIC_AUTH_TOKEN: "sk-test" } }, + createdAt: "2026-06-22T00:00:00Z", + updatedAt: "2026-06-22T00:00:00Z", + }; + } + return null; + }); + + renderPage(WORKSPACE_FIXTURE, onWorkspaceChange); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "导入配置" })); + await Promise.resolve(); + }); + + const dialog = await screen.findByRole("dialog", { name: "导入配置" }); + await waitFor(() => { + expect(within(dialog).getByText(/包含认证密钥/)).toBeInTheDocument(); + }); + const importButton = within(dialog).getByRole("button", { name: "导入" }); + expect(importButton).toBeDisabled(); + + fireEvent.click(within(dialog).getByRole("checkbox")); + expect(importButton).not.toBeDisabled(); + + await act(async () => { + fireEvent.click(importButton); + await Promise.resolve(); + }); + + expect(invokeMock).toHaveBeenCalledWith("import_profile_from_file", { + sourcePath: "/tmp/secret-config.json", + name: "secret-config", + description: "", + }); + await waitFor(() => { + expect(onWorkspaceChange).toHaveBeenCalled(); + }); + }); + + it("prefills deep link import name from i18n when backend name is empty", async () => { + invokeMock.mockImplementation(async (command: string) => { + if (command === "resolve_profile_import_deep_link") { + return { + name: "", + description: "", + settingsJson: '{\n "model": "claude-sonnet-4-6"\n}', + containsSecrets: false, + source: "payload", + }; + } + return null; + }); + + render( + + + {}} + deepLinkImportRequest={{ + urls: ["code-manager://profiles/import?payload=abc"], + requestId: 42, + }} + /> + + , + ); + + const dialog = await screen.findByRole("dialog", { name: "导入配置" }); + await waitFor(() => { + expect(within(dialog).getByLabelText("名称")).toHaveValue("导入的配置"); + }); + }); + it("blocks import confirmation when the file fails validation", async () => { openDialogMock.mockResolvedValue("/tmp/broken.json"); invokeMock.mockImplementation(async (command: string) => { diff --git a/src/components/__tests__/ui-system-contract.test.ts b/src/components/__tests__/ui-system-contract.test.ts index 732ff27..e7fa4a0 100644 --- a/src/components/__tests__/ui-system-contract.test.ts +++ b/src/components/__tests__/ui-system-contract.test.ts @@ -304,6 +304,10 @@ describe("ui system contract", () => { const statsSource = readFileSync("src/components/StatsPage.tsx", "utf8"); const usageSource = readFileSync("src/components/UsagePage.tsx", "utf8"); + // dense chart 10px 白名单:轴刻度 + 缓存命中率 ReferenceLine 标签 + expect(usageSource).toContain("TICK_STYLE_SM"); + expect(usageSource).toContain("CHART_REFERENCE_LABEL_STYLE"); + expect(usageSource).toContain("fontSize: 10"); const projectsSource = readFileSync("src/components/ProjectsPage.tsx", "utf8"); const historySource = readFileSync("src/components/HistoryPage.tsx", "utf8"); const historyProjectsSource = readFileSync("src/components/HistoryProjectList.tsx", "utf8"); diff --git a/src/i18n.ts b/src/i18n.ts index dec9d08..4f102af 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -240,6 +240,7 @@ const translations = { "profiles.import.dialogTitle": "导入配置", "profiles.import.dialogDescription": "从外部 JSON 配置文件导入为新配置。导入后不会自动启用,请按需填写名称并补全认证密钥。", + "profiles.import.defaultName": "导入的配置", "profiles.import.nameLabel": "名称", "profiles.import.namePlaceholder": "供应商-用途", "profiles.import.descriptionLabel": "描述", @@ -1945,6 +1946,7 @@ const translations = { "profiles.import.dialogTitle": "Import Profile", "profiles.import.dialogDescription": "Import an external JSON settings file as a new profile. It won't be applied automatically — set a name and fill in the auth token afterward.", + "profiles.import.defaultName": "Imported Profile", "profiles.import.nameLabel": "Name", "profiles.import.namePlaceholder": "provider-purpose", "profiles.import.descriptionLabel": "Description", diff --git a/src/lib/__tests__/settings-secrets.test.ts b/src/lib/__tests__/settings-secrets.test.ts new file mode 100644 index 0000000..17b9e3c --- /dev/null +++ b/src/lib/__tests__/settings-secrets.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + isSensitiveSettingsKey, + settingsJsonContainsSecrets, + settingsValueContainsSecrets, +} from "../settings-secrets"; + +describe("settings-secrets", () => { + it("detects sensitive key shapes aligned with backend", () => { + expect(isSensitiveSettingsKey("ANTHROPIC_AUTH_TOKEN")).toBe(true); + expect(isSensitiveSettingsKey("api_key")).toBe(true); + expect(isSensitiveSettingsKey("Authorization")).toBe(true); + expect(isSensitiveSettingsKey("model")).toBe(false); + }); + + it("flags nested non-empty secrets and ignores blank ones", () => { + expect( + settingsValueContainsSecrets({ + env: { ANTHROPIC_AUTH_TOKEN: "secret", ANTHROPIC_MODEL: "claude" }, + }), + ).toBe(true); + expect( + settingsValueContainsSecrets({ + env: { ANTHROPIC_AUTH_TOKEN: " ", ANTHROPIC_MODEL: "claude" }, + }), + ).toBe(false); + }); + + it("parses preview JSON text and fails closed on invalid JSON", () => { + expect(settingsJsonContainsSecrets('{"env":{"ANTHROPIC_API_KEY":"k"}}')).toBe(true); + expect(settingsJsonContainsSecrets('{"model":"claude"}')).toBe(false); + expect(settingsJsonContainsSecrets("not-json")).toBe(false); + }); +}); diff --git a/src/lib/settings-secrets.ts b/src/lib/settings-secrets.ts new file mode 100644 index 0000000..5abd0b4 --- /dev/null +++ b/src/lib/settings-secrets.ts @@ -0,0 +1,48 @@ +/** + * 与后端 `config::is_sensitive_settings_key` 对齐的敏感键判定, + * 供导入预览侧检测密钥风险(文件导入 / deep link 共用 UI 门禁)。 + */ +export function isSensitiveSettingsKey(key: string): boolean { + const normalized = key.toLowerCase(); + return ( + normalized === "authorization" || + normalized === "token" || + normalized.endsWith("_token") || + normalized.endsWith("-token") || + normalized.includes("secret") || + normalized.includes("password") || + normalized.includes("api_key") || + normalized.includes("api-key") || + normalized === "apikey" + ); +} + +export function settingsValueContainsSecrets(value: unknown): boolean { + if (value == null || typeof value !== "object") { + return false; + } + if (Array.isArray(value)) { + return value.some(settingsValueContainsSecrets); + } + for (const [key, child] of Object.entries(value as Record)) { + if (isSensitiveSettingsKey(key)) { + if (typeof child === "string") { + if (child.trim() !== "") return true; + } else if (child != null) { + return true; + } + } else if (settingsValueContainsSecrets(child)) { + return true; + } + } + return false; +} + +/** 从预览 JSON 文本检测是否含认证密钥;解析失败视为无密钥。 */ +export function settingsJsonContainsSecrets(jsonText: string): boolean { + try { + return settingsValueContainsSecrets(JSON.parse(jsonText) as unknown); + } catch { + return false; + } +} From be63e1645b83ce456e423d54d5027d4666e368b9 Mon Sep 17 00:00:00 2001 From: maguowei Date: Wed, 15 Jul 2026 10:34:40 +0800 Subject: [PATCH 4/6] =?UTF-8?q?test:=20=E8=A1=A5=E6=95=8F=E6=84=9F?= =?UTF-8?q?=E9=94=AE=E8=B7=A8=E8=AF=AD=E8=A8=80=20parity=20=E4=B8=8E?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=91=BD=E4=B8=AD=E7=8E=87=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E9=94=AE=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增共享语料 sensitive-settings-keys.json 作为敏感键预期单一事实源, 前后端 config.rs 与 settings-secrets.ts 各写测试对照它,任一端漂移即红 - 补 UsagePage 缓存命中率多系列排序断言,锁定按纯 input tokens 降序 Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/src/config.rs | 45 +++++++++++++ .../fixtures/sensitive-settings-keys.json | 31 +++++++++ src/components/__tests__/UsagePage.test.tsx | 66 +++++++++++++++++++ .../__tests__/sensitive-key-parity.test.ts | 33 ++++++++++ 4 files changed, 175 insertions(+) create mode 100644 src-tauri/tests/fixtures/sensitive-settings-keys.json create mode 100644 src/lib/__tests__/sensitive-key-parity.test.ts diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 731cb48..e28da50 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -2969,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/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/components/__tests__/UsagePage.test.tsx b/src/components/__tests__/UsagePage.test.tsx index 5463cb9..bf0b020 100644 --- a/src/components/__tests__/UsagePage.test.tsx +++ b/src/components/__tests__/UsagePage.test.tsx @@ -379,6 +379,55 @@ const multiModelTimeSeries: UsageTimeSeriesPoint[] = [ }, ]; +// 缓存命中率多系列排序键为“纯 input tokens”(见 UsagePage.tsx modelSeries 排序)。 +// 本 fixture 刻意让两模型在纯 input 与混合体量(input+create+read)上顺序相反: +// opus 纯 input 300k、无缓存 → 纯 input 领先、混合体量落后 +// sonnet 纯 input 100k、cacheRead 900k → 纯 input 落后、混合体量领先 +// 断言 opus 排在 sonnet 前,即证明排序键是纯 input,而非旧的混合体量。 +const cacheHitSortTimeSeries: UsageTimeSeriesPoint[] = [ + { + bucket: "2026-05-23 09:00", + bucketStartMs: Date.UTC(2026, 4, 23, 9, 0), + messages: 30, + sessions: 5, + inputTokens: 400_000, + outputTokens: 200_000, + cacheCreationTokens: 0, + cacheReadTokens: 900_000, + webSearchRequests: 0, + webFetchRequests: 0, + cost: 10, + inputCost: 4, + outputCost: 4, + cacheCreationCost: 0, + cacheReadCost: 2, + byModel: [ + { + model: "claude-3-opus", + messages: 12, + inputTokens: 300_000, + outputTokens: 120_000, + cacheCreationTokens: 0, + cacheReadTokens: 0, + webSearchRequests: 0, + webFetchRequests: 0, + cost: 8, + }, + { + model: "claude-3-7-sonnet", + messages: 18, + inputTokens: 100_000, + outputTokens: 80_000, + cacheCreationTokens: 0, + cacheReadTokens: 900_000, + webSearchRequests: 0, + webFetchRequests: 0, + cost: 2, + }, + ], + }, +]; + function makeUsage( overrides: Partial<{ tab: UsageTab; @@ -831,6 +880,23 @@ describe("UsagePage cost cockpit", () => { } }); + it("orders cache hit-rate model series by pure input tokens", () => { + renderUsage({ timeSeries: cacheHitSortTimeSeries }); + + const cacheHitLegend = screen.getByRole("list", { name: "缓存命中率" }); + // 用 role+name 定位图例项,再比 DOM 相对顺序,避免 textContent 下标在列表混入额外控件时失真。 + const overall = within(cacheHitLegend).getByRole("button", { name: /总体命中率/ }); + const opus = within(cacheHitLegend).getByRole("button", { name: /claude-3-opus/ }); + const sonnet = within(cacheHitLegend).getByRole("button", { name: /claude-3-7-sonnet/ }); + const buttons = within(cacheHitLegend).getAllByRole("button"); + + // 总体命中率始终置顶;模型系列按纯 input 降序:opus(300k) 先于 sonnet(100k)。 + // 若排序键回退为混合体量(input+create+read),sonnet(1.0M) 会领先,本断言即变红。 + expect(buttons.indexOf(overall)).toBe(0); + expect(buttons.indexOf(opus)).toBeGreaterThan(0); + expect(buttons.indexOf(opus)).toBeLessThan(buttons.indexOf(sonnet)); + }); + it("uses a distinct non-white total series color and subtle bar focus styling", () => { renderUsage({ timeSeries: multiModelTimeSeries }); diff --git a/src/lib/__tests__/sensitive-key-parity.test.ts b/src/lib/__tests__/sensitive-key-parity.test.ts new file mode 100644 index 0000000..90bcb19 --- /dev/null +++ b/src/lib/__tests__/sensitive-key-parity.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { isSensitiveSettingsKey } from "../settings-secrets"; + +// 敏感键判定同时存在于前端 settings-secrets.ts 与后端 config.rs::is_sensitive_settings_key, +// 两端各自内联维护敏感键判定规则(精确匹配 + 前后缀/子串模式)。 +// 用同一份共享语料作为预期结果的单一事实源,任一端实现漂移即红。 +// 后端对照断言见 src-tauri/src/config.rs::is_sensitive_settings_key_matches_shared_fixture。 +const fixturePath = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../src-tauri/tests/fixtures/sensitive-settings-keys.json", +); +const cases = JSON.parse(readFileSync(fixturePath, "utf-8")) as Array<{ + key: string; + sensitive: boolean; +}>; + +// 防止语料被清空后 it.each([]) 空跑仍绿 +const MIN_FIXTURE_CASES = 20; + +describe("sensitive-key parity", () => { + it("loads a non-empty shared fixture with both polarities", () => { + expect(cases.length).toBeGreaterThanOrEqual(MIN_FIXTURE_CASES); + expect(cases.some((c) => c.sensitive)).toBe(true); + expect(cases.some((c) => !c.sensitive)).toBe(true); + }); + + it.each(cases)("$key -> $sensitive", ({ key, sensitive }) => { + expect(isSensitiveSettingsKey(key)).toBe(sensitive); + }); +}); From 2bc88caf3381f2531f38bbf9b7a511496ba00dc9 Mon Sep 17 00:00:00 2001 From: maguowei Date: Wed, 15 Jul 2026 11:16:04 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20deep=20link=20=E6=94=B9=20peek/ack?= =?UTF-8?q?=20=E9=98=B2=E5=88=87=E9=A1=B5=E4=B8=A2=E9=93=BE=EF=BC=8C?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=AF=B9=E9=BD=90=E8=BD=AF=E9=93=BE?= =?UTF-8?q?=E5=86=99=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置导入队列权威源留在后端:peek 预览、导入/取消/失败后 ack;切页不丢。 用户级 open_claude_file_in_editor 与项目侧一致拒绝经软链路径,避免出界写入口。 --- .claude/rules/tauri-backend.md | 4 +- ...ctory-overview-readonly-follow-symlinks.md | 13 +- docs/adr/0003-deeplink-profile-import.md | 2 +- src-tauri/src/claude_directory.rs | 47 ++- src-tauri/src/deep_link.rs | 145 ++++++++- src-tauri/src/lib.rs | 7 +- src/App.test.tsx | 51 +-- src/App.tsx | 55 ++-- src/bindings.ts | 11 +- src/components/ProfilesPage.tsx | 119 ++++--- .../__tests__/ProfilesPage.test.tsx | 298 ++++++++---------- 11 files changed, 456 insertions(+), 296 deletions(-) diff --git a/.claude/rules/tauri-backend.md b/.claude/rules/tauri-backend.md index 06981da..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` 读取、会话详情解析、轮询变更 | @@ -86,7 +86,7 @@ const workspace = await ipc.getConfigWorkspace(); - 后端继续负责配置合并、路径校验、目录遍历安全、真实落盘和日志脱敏。 - 路径相关 command 必须防止绝对路径和 `..` 路径逃逸。 - **只读跟随软链(ADR 0002)**:`claude_directory` 与项目级 `.claude` 的**扫描 / 预览**允许跟随符号链接,目标可在受控 root 外;相对路径仍禁止 `..` / 绝对段。用户级与项目级(含项目 `.claude` 根目录本身是软链)共用该只读契约。 -- **写操作仍拒绝软链路径**:新建 / 重命名 / 删除等写路径一旦经过软链组件必须拒绝;项目侧创建设置文件时若 `.claude` 根是软链也拒绝。 +- **写操作与外部编辑器仍拒绝软链路径**:新建 / 重命名 / 删除,以及 `open_claude_file_in_editor` / `open_project_claude_file_in_editor`,路径一旦经过软链组件必须拒绝;项目侧创建设置文件时若 `.claude` 根是软链也拒绝。 - 日志脱敏字段清单与日志格式规范见 `.claude/rules/projects-tray-diagnostics.md` 的「日志与诊断」一节,不要在两处维护副本。 ## 用量 runtime 与 SQLite 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/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/deep_link.rs b/src-tauri/src/deep_link.rs index 019abb2..efb42b8 100644 --- a/src-tauri/src/deep_link.rs +++ b/src-tauri/src/deep_link.rs @@ -29,12 +29,59 @@ pub const REMOTE_FETCH_TIMEOUT: Duration = Duration::from_secs(10); 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())?; + // 仅与队尾去重,避免同一次系统连发重复入队 + if guard.back() != Some(&url) { + guard.push_back(url); + } + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ResolvedProfileImportDeepLink { @@ -120,17 +167,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 count_pending_profile_import_deep_links( + pending: State<'_, PendingProfileImportDeepLinks>, +) -> Result { + pending.len() +} + +/// 确认结束当前队头:仅当队头等于 `url` 时移除。 +/// 导入成功、用户取消、解析失败后调用;切页不应调用。 #[tauri::command] #[specta::specta] -pub fn drain_pending_profile_import_deep_links( +pub fn ack_profile_import_deep_link( pending: State<'_, PendingProfileImportDeepLinks>, -) -> Result, String> { - let mut guard = pending - .urls - .lock() - .map_err(|_| "深度链接队列锁异常".to_string())?; - Ok(guard.drain(..).collect()) + 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 +246,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, ()); @@ -548,6 +616,55 @@ 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_tail() { + let pending = PendingProfileImportDeepLinks::default(); + let url = "code-manager://profiles/import?payload=a"; + pending.push_url(url.into()).unwrap(); + pending.push_url(url.into()).unwrap(); + assert_eq!(pending.len().unwrap(), 1); + } + #[test] fn settings_contain_secrets_detects_non_empty_token() { let value = json!({ 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/App.test.tsx b/src/App.test.tsx index e0992f2..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; @@ -833,17 +836,25 @@ describe("App", () => { }, ], }; - let drainCount = 0; - let pendingUrls: string[] = []; - invokeMock.mockImplementation(async (command) => { + let peekCount = 0; + let pendingHead: string | null = null; + invokeMock.mockImplementation(async (command, args) => { if (command === "get_config_workspace") { return workspaceWithProfile; } - if (command === "drain_pending_profile_import_deep_links") { - drainCount += 1; - const urls = pendingUrls; - pendingUrls = []; - return urls; + 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 { @@ -861,7 +872,7 @@ describe("App", () => { await waitFor(() => { expect(screen.queryByText("加载中...")).not.toBeInTheDocument(); }); - const drainAfterLoad = drainCount; + const peekAfterLoad = peekCount; // 打开配置编辑器并制造 dirty fireEvent.click( @@ -870,26 +881,26 @@ describe("App", () => { const nameInput = await screen.findByDisplayValue("OpenRouter User", {}, { timeout: 5000 }); fireEvent.change(nameInput, { target: { value: "OpenRouter User Draft" } }); - // 后端积压一条 deep link;脏编辑器时应 requestExit 且不 drain IPC - pendingUrls = ["code-manager://profiles/import?payload=abc"]; + // 后端积压一条 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(); }); - expect(drainCount).toBe(drainAfterLoad); + // 脏态:只 requestExit,不 peek(wake 在 guard 处短路) + expect(peekCount).toBe(peekAfterLoad); expect(screen.getByRole("heading", { name: "存在未保存的更改" })).toBeInTheDocument(); - // 继续编辑:仍不 drain + // 继续编辑:仍不 peek 唤醒 fireEvent.click(screen.getByRole("button", { name: "继续编辑" })); await waitFor(() => { expect(screen.queryByRole("heading", { name: "存在未保存的更改" })).not.toBeInTheDocument(); }); - expect(drainCount).toBe(drainAfterLoad); + expect(peekCount).toBe(peekAfterLoad); - // 关闭抽屉:dirty 时再弹 exit-guard,不保存退出后解除 guard → force drain - // pendingUrls 在首次事件时未 drain,仍保留在 mock 队列中 + // 关闭抽屉:dirty 时再弹 exit-guard,不保存退出后解除 guard → force wake → peek fireEvent.click(screen.getByRole("button", { name: "关闭" })); await waitFor(() => { expect(screen.getByRole("heading", { name: "存在未保存的更改" })).toBeInTheDocument(); @@ -897,7 +908,7 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: "不保存退出" })); await waitFor(() => { - expect(drainCount).toBeGreaterThan(drainAfterLoad); + expect(peekCount).toBeGreaterThan(peekAfterLoad); }); expect( await screen.findByRole("dialog", { name: "导入配置" }, { timeout: 5000 }), diff --git a/src/App.tsx b/src/App.tsx index ceb0890..6f9b896 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,18 +91,15 @@ 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 drainProfileImportDeepLinksRef = useRef<(options?: { force?: boolean }) => Promise>( + const wakeProfileImportDeepLinksRef = useRef<(options?: { force?: boolean }) => Promise>( async () => {}, ); const loadWorkspace = useCallback(async () => { @@ -172,25 +169,22 @@ function App() { showToastRef.current = showToast; tRef.current = t; - // 配置导入 deep link:drain 后端 pending 队列,切到配置页交给 ProfilesPage 排队预览。 + // 配置导入 deep link:peek 后端队列是否有待处理项,有则切到配置页并递增 wake token。 + // URL 始终留在后端直到 ProfilesPage ack;切页不丢链。 // force=true 跳过 exit-guard(用户已确认离开,或 guard 刚解除后的重试)。 - // 脏编辑器时只 requestExit、**不** drain;取消后 URL 仍在后端队列,guard 解除时再试。 - const drainProfileImportDeepLinks = useCallback(async (options?: { force?: boolean }) => { + // 脏编辑器时只 requestExit、**不** 切页唤醒;URL 仍在后端,guard 解除时再试。 + const wakeProfileImportDeepLinks = useCallback(async (options?: { force?: boolean }) => { if (!isTauri()) return; if (!options?.force && editorExitGuardRef.current) { editorExitGuardRef.current.requestExit(() => { - void drainProfileImportDeepLinksRef.current({ force: true }); + void wakeProfileImportDeepLinksRef.current({ force: true }); }); return; } try { - const urls = await ipc.drainPendingProfileImportDeepLinks(); - if (!urls?.length) return; - deepLinkImportRequestIdRef.current += 1; - setDeepLinkImportRequest({ - urls, - requestId: deepLinkImportRequestIdRef.current, - }); + const head = await ipc.peekPendingProfileImportDeepLink(); + if (!head) return; + setDeepLinkWakeToken((token) => token + 1); activateTabRef.current("configs"); } catch (error) { showOperationError( @@ -200,14 +194,14 @@ function App() { ); } }, []); - drainProfileImportDeepLinksRef.current = drainProfileImportDeepLinks; + wakeProfileImportDeepLinksRef.current = wakeProfileImportDeepLinks; const setEditorExitGuard = useCallback((guard: EditorExitGuard | null) => { const hadGuard = editorExitGuardRef.current != null; editorExitGuardRef.current = guard; - // 编辑器关闭/解除保护后重试 pending deep link(覆盖「继续编辑」未 drain 的场景) + // 编辑器关闭/解除保护后重试 pending deep link(覆盖「继续编辑」未唤醒的场景) if (hadGuard && guard == null) { - void drainProfileImportDeepLinksRef.current({ force: true }); + void wakeProfileImportDeepLinksRef.current({ force: true }); } }, []); @@ -238,11 +232,7 @@ function App() { if (activeTab !== "usage") { setUsageProjectRequest(null); } - // 与 history/usage 一致:离开 configs 时丢弃未消费的 deep link request, - // 防止 lazy ProfilesPage 在 effect 入队前卸载后残留 state 再次投递 - if (activeTab !== "configs") { - setDeepLinkImportRequest(null); - } + // deep link 权威源在后端 pending:离开 configs 不 ack、不清队列 }, [activeTab]); useTauriEvent("navigate-to-tab", (tab) => { @@ -330,19 +320,13 @@ function App() { [activateTab, runWithEditorExitGuard], ); - // ProfilesPage 接管 urls 入队后按 requestId 清空,避免残留 state 在 remount 时重放; - // 仅匹配当前 id 时清空,防止旧请求的 clear 冲掉并发 drain 的新 request - const handleDeepLinkImportConsumed = useCallback((requestId: number) => { - setDeepLinkImportRequest((current) => (current?.requestId === requestId ? null : current)); - }, []); - useEffect(() => { if (loading) return; - void drainProfileImportDeepLinks(); - }, [loading, drainProfileImportDeepLinks]); + void wakeProfileImportDeepLinks(); + }, [loading, wakeProfileImportDeepLinks]); useTauriEvent("profile-import-deep-link", () => { - void drainProfileImportDeepLinks(); + void wakeProfileImportDeepLinks(); }); if (loading) { return ( @@ -408,8 +392,7 @@ function App() { workspace={workspace} onWorkspaceChange={loadWorkspace} onEditorExitGuardChange={setEditorExitGuard} - deepLinkImportRequest={deepLinkImportRequest} - onDeepLinkImportConsumed={handleDeepLinkImportConsumed} + 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 805dd0d..1196c0c 100644 --- a/src/components/ProfilesPage.tsx +++ b/src/components/ProfilesPage.tsx @@ -38,6 +38,7 @@ import type { UnmanagedUserSettings, UnmanagedUserSettingsImportStatus, } from "../types"; +import { isTauri } from "../types"; import ConfigPreview from "./ConfigPreview"; import ConfirmAlertDialog from "./ConfirmAlertDialog"; import { @@ -104,13 +105,11 @@ interface ProfilesPageProps { workspace: ConfigWorkspace; onWorkspaceChange: () => Promise; onEditorExitGuardChange?: (guard: EditorExitGuard | null) => void; - /** App 从 deep link pending 队列 drain 后下发的导入请求 */ - deepLinkImportRequest?: { urls: string[]; requestId: number } | null; /** - * ProfilesPage 接管 urls 入队后通知 App 清空对应 request。 - * 必须带 requestId,App 侧仅在仍匹配时清空,避免冲掉并发 drain 的新请求。 + * App 在确认后端仍有 pending deep link 后递增的唤醒令牌。 + * ProfilesPage 据此 peek 队头;URL 权威源在 Rust,ack 前切页不丢。 */ - onDeepLinkImportConsumed?: (requestId: number) => void; + deepLinkWakeToken?: number; } /** 导入对话框来源:本地文件或已解析的 deep link */ @@ -274,8 +273,7 @@ function ProfilesPage({ workspace, onWorkspaceChange, onEditorExitGuardChange, - deepLinkImportRequest = null, - onDeepLinkImportConsumed, + deepLinkWakeToken = 0, }: ProfilesPageProps) { const { language, t } = useI18n(); const { showToast } = useToast(); @@ -313,14 +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); - // 入队后回调 App 清空 request;走 ref 避免 effect 因回调引用变化重跑 - const onDeepLinkImportConsumedRef = useRef(onDeepLinkImportConsumed); - onDeepLinkImportConsumedRef.current = onDeepLinkImportConsumed; + // 当前预览对应的队头 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; @@ -904,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(""); @@ -913,8 +912,22 @@ 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 共用:预览含密钥时须勾选确认 @@ -933,6 +946,8 @@ function ProfilesPage({ } 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( @@ -947,25 +962,42 @@ function ProfilesPage({ 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({ @@ -979,36 +1011,49 @@ function ProfilesPage({ 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。 - // 入队后立即按 requestId 通知 App 清空:urls 已转入本地 queue,App 若残留该 state, - // 会在 ProfilesPage 条件渲染 remount 时被重新投递,导致用户放弃后切回 configs 又弹一次。 + // 新 wake token 或首次挂载:从后端 peek 恢复/继续处理(切页 remount 也走这里) useEffect(() => { - if (!deepLinkImportRequest) return; - if (lastDeepLinkRequestIdRef.current === deepLinkImportRequest.requestId) return; - const { requestId, urls } = deepLinkImportRequest; - lastDeepLinkRequestIdRef.current = requestId; - deepLinkQueueRef.current.push(...urls); - onDeepLinkImportConsumedRef.current?.(requestId); + 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(() => { @@ -2374,7 +2419,7 @@ function ProfilesPage({
-