From 3ec3b8dd067d76ac7c7b2cac4b2fef09aa651244 Mon Sep 17 00:00:00 2001 From: Wangnov Date: Tue, 2 Dec 2025 13:41:45 +0800 Subject: [PATCH 01/10] =?UTF-8?q?docs(guidelines):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=8D=8F=E4=BD=9C=E8=A7=84=E8=8C=83=E4=B8=8E=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 +++ CLAUDE.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0dead13..1109c65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,9 @@ last-updated: 2025-11-23 - `npm run check:fix`:修复版入口,顺序同上,遇可修复项会自动 `--fix`。 - `npm run tauri dev`:本地启动 Tauri 应用进行端到端手动验证。 - `npm run tauri build`: 本地构建 Tauri 应用安装包。 +- `npm run test` / `npm run test:rs`:后端 Rust 单测(当前无前端测试,test 等同 test:rs)。 - `cargo test --locked`:Rust 单测执行器;缺乏覆盖时请补测试后再运行。 +- `npm run coverage:rs`:后端覆盖率检查(基于 cargo-llvm-cov,默认行覆盖阈值 90%,需先安装 llvm-tools-preview 与 cargo-llvm-cov,可运行 `npm run coverage:rs:setup` 自动安装依赖)。 ## 日常开发流程 @@ -103,6 +105,7 @@ last-updated: 2025-11-23 - `useBalanceMonitor` hook 负责自动刷新逻辑,支持配置级别的刷新间隔 - 配置表单(`ConfigFormDialog`)支持模板选择、代码编辑、静态 headers(JSON 格式) - 卡片视图(`ConfigCard`)展示余额信息、使用比例、到期时间、错误提示 +- Profile Center 已为三工具保存完整原生快照:Claude(settings.json + config.json,可选)、Codex(config.toml + auth.json)、Gemini(settings.json + .env),导入/激活/监听都会覆盖附属文件。 - **新用户引导系统**: - 首次启动强制引导,配置存储在 `GlobalConfig.onboarding_status: Option`(包含已完成版本、跳过步骤、完成时间) - 版本化管理,支持增量更新(v1 -> v2 只展示新增内容),独立的引导内容版本号(与应用版本解耦) diff --git a/CLAUDE.md b/CLAUDE.md index 0bc7c87..3de355a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,9 @@ last-updated: 2025-11-23 - `npm run check:fix`:修复版入口,顺序同上,遇可修复项会自动 `--fix`。 - `npm run tauri dev`:本地启动 Tauri 应用进行端到端手动验证。 - `npm run tauri build`: 本地构建 Tauri 应用安装包。 +- `npm run test` / `npm run test:rs`:后端 Rust 单测(当前无前端测试,test 等同 test:rs)。 - `cargo test --locked`:Rust 单测执行器;缺乏覆盖时请补测试后再运行。 +- `npm run coverage:rs`:后端覆盖率检查(基于 cargo-llvm-cov,默认行覆盖阈值 90%,需先安装 llvm-tools-preview 与 cargo-llvm-cov,可运行 `npm run coverage:rs:setup` 自动安装依赖)。 ## 日常开发流程 @@ -103,6 +105,7 @@ last-updated: 2025-11-23 - `useBalanceMonitor` hook 负责自动刷新逻辑,支持配置级别的刷新间隔 - 配置表单(`ConfigFormDialog`)支持模板选择、代码编辑、静态 headers(JSON 格式) - 卡片视图(`ConfigCard`)展示余额信息、使用比例、到期时间、错误提示 +- Profile Center 已为三工具保存完整原生快照:Claude(settings.json + config.json,可选)、Codex(config.toml + auth.json)、Gemini(settings.json + .env),导入/激活/监听都会覆盖附属文件。 - **新用户引导系统**: - 首次启动强制引导,配置存储在 `GlobalConfig.onboarding_status: Option`(包含已完成版本、跳过步骤、完成时间) - 版本化管理,支持增量更新(v1 -> v2 只展示新增内容),独立的引导内容版本号(与应用版本解耦) From 2fdfd778ad07d63c2f964eee96d73099f771a7e0 Mon Sep 17 00:00:00 2001 From: Wangnov Date: Tue, 2 Dec 2025 13:42:05 +0800 Subject: [PATCH 02/10] =?UTF-8?q?chore(deps):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E4=B8=8E=E6=9E=84=E5=BB=BA=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 32 ++++++++++++++++++++++++++++++++ package.json | 5 +++++ src/utils/formatting.ts | 5 +++-- vite.config.ts | 2 ++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1a7433c..66816ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", @@ -1803,6 +1804,37 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", diff --git a/package.json b/package.json index eb96c42..71cbccb 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "fmt:rs": "cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check", "fmt:rs:fix": "cargo fmt --manifest-path src-tauri/Cargo.toml --all", "format": "npm run fmt:ts:fix", + "test": "npm run test:rs", + "test:rs": "cargo test --manifest-path src-tauri/Cargo.toml --workspace --locked", + "coverage:rs": "cargo llvm-cov --manifest-path src-tauri/Cargo.toml --workspace --locked --fail-under-lines 90", + "coverage:rs:setup": "rustup component add llvm-tools-preview && cargo install cargo-llvm-cov --locked", "guidelines:check": "node scripts/ensure-guidelines-sync.mjs --mode=check", "guidelines:fix": "node scripts/ensure-guidelines-sync.mjs --mode=fix", "check": "node scripts/run-checks.mjs", @@ -45,6 +49,7 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index 8d9dcd7..ea3a2e3 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -1,3 +1,5 @@ +import { open } from '@tauri-apps/plugin-shell'; + /** * 版本号格式化 * 保留 preview/beta 等标记 @@ -34,8 +36,7 @@ export function maskApiKey(key: string): string { */ export async function openExternalLink(url: string): Promise { try { - // 动态导入 shell 插件 - const { open } = await import('@tauri-apps/plugin-shell'); + // 使用静态导入的 open(避免 Rollup 混用动态/静态导致分包警告) await open(url); console.log('链接已在浏览器中打开:', url); } catch (error) { diff --git a/vite.config.ts b/vite.config.ts index 06ab35d..3dda878 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -39,6 +39,8 @@ export default defineConfig(async () => ({ 'chart-vendor': ['recharts', 'date-fns'], // 拆分 UI 组件库 'ui-vendor': ['lucide-react'], + // Tauri 相关依赖单独打包,避免挤入主 chunk + 'tauri-vendor': ['@tauri-apps/api', '@tauri-apps/plugin-shell'], }, }, }, From 99d5072610dd599450238f426153c8bb83259e47 Mon Sep 17 00:00:00 2001 From: Wangnov Date: Tue, 2 Dec 2025 13:42:22 +0800 Subject: [PATCH 03/10] =?UTF-8?q?chore(schemas):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E4=B8=8E=E6=96=B0=E5=A2=9E=20schema=20=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../claude_code_settings.schema.json | 383 +++- src-tauri/resources/codex_config.schema.json | 214 ++- .../resources/gemini_cli_settings.schema.json | 59 +- .../original-schema/claude-code-schema.json | 695 +++++++ .../original-schema/gemini-cli-schema.json | 1707 +++++++++++++++++ .../original-schema/source-links.json | 4 + 6 files changed, 2982 insertions(+), 80 deletions(-) create mode 100644 src-tauri/resources/original-schema/claude-code-schema.json create mode 100644 src-tauri/resources/original-schema/gemini-cli-schema.json create mode 100644 src-tauri/resources/original-schema/source-links.json diff --git a/src-tauri/resources/claude_code_settings.schema.json b/src-tauri/resources/claude_code_settings.schema.json index 23ce996..4d9e2a5 100644 --- a/src-tauri/resources/claude_code_settings.schema.json +++ b/src-tauri/resources/claude_code_settings.schema.json @@ -3,27 +3,54 @@ "$id": "https://json.schemastore.org/claude-code-settings.json", "$defs": { "hookCommand": { - "type": "object", - "description": "钩子命令配置。", - "additionalProperties": false, - "required": ["type", "command"], - "properties": { - "type": { - "type": "string", - "description": "钩子实现的类型。", - "const": "command" - }, - "command": { - "type": "string", - "description": "要执行的 shell 命令。", - "minLength": 1 + "anyOf": [ + { + "type": "object", + "description": "Bash 命令钩子配置。", + "additionalProperties": false, + "required": ["type", "command"], + "properties": { + "type": { + "type": "string", + "description": "钩子实现的类型。", + "const": "command" + }, + "command": { + "type": "string", + "description": "要执行的 shell 命令。", + "minLength": 1 + }, + "timeout": { + "type": "number", + "description": "该命令的可选超时时间(秒)。", + "exclusiveMinimum": 0 + } + } }, - "timeout": { - "type": "number", - "description": "该命令的可选超时时间(秒)。", - "exclusiveMinimum": 0 + { + "type": "object", + "description": "LLM 提示钩子配置,prompt 中可使用 $ARGUMENTS 占位符表示钩子输入 JSON。", + "additionalProperties": false, + "required": ["type", "prompt"], + "properties": { + "type": { + "type": "string", + "description": "钩子实现的类型。", + "const": "prompt" + }, + "prompt": { + "type": "string", + "description": "要发送给 LLM 的提示文本,支持 $ARGUMENTS 占位符。", + "minLength": 1 + }, + "timeout": { + "type": "number", + "description": "该提示的可选超时时间(秒)。", + "exclusiveMinimum": 0 + } + } } - } + ] }, "hookMatcher": { "type": "object", @@ -404,6 +431,38 @@ }, "examples": [["filesystem"]] }, + "allowedMcpServers": { + "type": "array", + "description": "企业侧维护的 MCP 服务器允许列表,适用于所有来源(含 managed-mcp.json)。未设置表示全部允许,空数组表示全部禁止。若同时出现在 deny 列表,则以 deny 优先。", + "items": { + "type": "object", + "properties": { + "serverName": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "description": "允许配置的 MCP 服务器名称。" + } + }, + "required": ["serverName"], + "additionalProperties": false + } + }, + "deniedMcpServers": { + "type": "array", + "description": "企业侧维护的 MCP 服务器拒绝列表,命中后在所有来源中都会被拦截;与 allow 冲突时也以此为准。", + "items": { + "type": "object", + "properties": { + "serverName": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "description": "被禁止的 MCP 服务器名称。" + } + }, + "required": ["serverName"], + "additionalProperties": false + } + }, "hooks": { "type": "object", "description": "在 Claude Code 生命周期关键节点触发的钩子配置。", @@ -560,6 +619,292 @@ "description": "以 JSON 形式导出 AWS 凭据的命令。", "minLength": 1, "examples": ["/bin/generate_aws_grant.sh"] + }, + "enabledPlugins": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + }, + { + "not": {} + } + ] + }, + "description": "已启用的插件,键为 plugin-id@marketplace-id,可用布尔值或数组配置(示例:{\"formatter@anthropic-tools\": true})。也支持带版本约束的扩展格式。" + }, + "extraKnownMarketplaces": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "source": { + "anyOf": [ + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string", + "format": "uri", + "description": "marketplace.json 的直接 URL。" + } + }, + "required": ["source", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "github" + }, + "repo": { + "type": "string", + "description": "GitHub 仓库(owner/repo)。" + }, + "ref": { + "type": "string", + "description": "使用的 Git 分支或 tag(如 \"main\"、\"v1.0.0\"),默认仓库默认分支。" + }, + "path": { + "type": "string", + "description": "仓库内 marketplace.json 的路径(默认 .claude-plugin/marketplace.json)。" + } + }, + "required": ["source", "repo"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "git" + }, + "url": { + "type": "string", + "pattern": "\\\\.git$", + "description": "完整的 git 仓库地址。" + }, + "ref": { + "type": "string", + "description": "使用的 Git 分支或 tag(如 \"main\"、\"v1.0.0\"),默认仓库默认分支。" + }, + "path": { + "type": "string", + "description": "仓库内 marketplace.json 的路径(默认 .claude-plugin/marketplace.json)。" + } + }, + "required": ["source", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "npm" + }, + "package": { + "type": "string", + "description": "包含 marketplace.json 的 NPM 包名。" + } + }, + "required": ["source", "package"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "file" + }, + "path": { + "type": "string", + "description": "本地 marketplace.json 文件路径。" + } + }, + "required": ["source", "path"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "directory" + }, + "path": { + "type": "string", + "description": "包含 .claude-plugin/marketplace.json 的本地目录。" + } + }, + "required": ["source", "path"], + "additionalProperties": false + } + ], + "description": "指定 marketplace 的获取方式。" + }, + "installLocation": { + "type": "string", + "description": "marketplace 清单的本地缓存位置(未提供时会自动生成)。" + } + }, + "required": ["source"], + "additionalProperties": false + }, + "description": "为当前仓库提供的额外 marketplace 源,常用于团队项目协作。" + }, + "skippedMarketplaces": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "用户在提示安装时选择跳过的 marketplace 名称列表。" + }, + "skippedPlugins": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "用户在提示安装时选择跳过的插件列表(plugin@marketplace)。" + }, + "otelHeadersHelper": { + "type": "string", + "description": "输出 OpenTelemetry 头部的脚本路径。", + "minLength": 1 + }, + "skipWebFetchPreflight": { + "type": "boolean", + "description": "在安全策略严格的企业环境下跳过 WebFetch 的阻断检查。" + }, + "sandbox": { + "type": "object", + "properties": { + "network": { + "type": "object", + "properties": { + "allowUnixSockets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "允许的本地 Unix Socket 路径(如 SSH agent、Docker 等),未配置默认阻止。" + }, + "allowLocalBinding": { + "type": "boolean", + "description": "允许绑定本地网络地址(如 localhost 端口),未配置默认关闭。" + }, + "httpProxyPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "用于网络过滤的 HTTP 代理端口,未指定会自动启动代理。" + }, + "socksProxyPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "用于网络过滤的 SOCKS 代理端口,未指定会自动启动代理。" + } + }, + "additionalProperties": false + }, + "ignoreViolations": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + }, + "description": "当命令模式匹配时忽略哪些路径的沙箱违规,可用 \"*\" 匹配全部命令。" + }, + "description": "命令模式到忽略的文件路径列表的映射。" + }, + "excludedCommands": { + "type": "array", + "items": { + "type": "string" + }, + "description": "永不在沙箱中运行的命令(例如 [\"git\", \"docker\"])。" + }, + "autoAllowBashIfSandboxed": { + "type": "boolean", + "description": "当命令在沙箱中运行时自动放行 Bash,无需确认(仅对将被沙箱化的命令生效)。" + }, + "enableWeakerNestedSandbox": { + "type": "boolean", + "description": "在某些无法挂载 /proc 的无特权 docker 环境下启用较弱的嵌套沙箱(安全性降低,默认 false)。" + }, + "allowUnsandboxedCommands": { + "type": "boolean", + "description": "允许通过 dangerouslyDisableSandbox 关闭沙箱执行。为 false 时会忽略该参数,所有命令都必须沙箱化(默认 true)。" + }, + "enabled": { + "type": "boolean", + "description": "是否启用沙箱 Bash。" + } + }, + "additionalProperties": false + }, + "companyAnnouncements": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "启动时展示的公司公告(如有多条会随机展示一条)。" + }, + "pluginConfigs": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "mcpServers": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "description": "按服务器名称存储的 MCP 服务器用户配置。" + } + }, + "additionalProperties": false + }, + "description": "按插件(plugin@marketplace)存储的配置,包括 MCP 服务器的用户配置。" } } } diff --git a/src-tauri/resources/codex_config.schema.json b/src-tauri/resources/codex_config.schema.json index 2a787ff..46f68ea 100644 --- a/src-tauri/resources/codex_config.schema.json +++ b/src-tauri/resources/codex_config.schema.json @@ -1,30 +1,25 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://schema.duckcoding.com/codex-config.json", "description": "Codex CLI 的 config.toml 配置结构(翻译自官方文档),用于前端渲染与校验。", "type": "object", "additionalProperties": true, "$defs": { "featureFlags": { "type": "object", - "description": "可选实验特性的布尔开关表。", + "description": "可选/实验特性的布尔开关表,对应 [features]。", "additionalProperties": false, "properties": { "unified_exec": { "type": "boolean", - "description": "启用统一的 PTY exec 工具。" - }, - "streamable_shell": { - "type": "boolean", - "description": "启用可流式的 exec/write-stdin 工具。" + "description": "使用统一的 PTY exec 工具。" }, "rmcp_client": { "type": "boolean", - "description": "允许 streamable HTTP MCP 使用 OAuth。" + "description": "启用 RMCP(HTTP MCP + OAuth)功能。" }, "apply_patch_freeform": { "type": "boolean", - "description": "暴露自由格式的 apply_patch 工具。" + "description": "包含自由格式的 apply_patch 工具。" }, "view_image_tool": { "type": "boolean", @@ -34,17 +29,33 @@ "type": "boolean", "description": "允许模型请求 Web 搜索。" }, - "experimental_sandbox_command_assessment": { + "exec_policy": { "type": "boolean", - "description": "启用模型对 sandbox 风险的评估。" + "description": "启用 exec 执行策略/风险评估(shell/unified exec 前置校验)。" }, - "ghost_commit": { + "experimental_sandbox_command_assessment": { "type": "boolean", - "description": "每轮创建一次 ghost commit。" + "description": "启用模型对 sandbox 命令风险的评估。" }, "enable_experimental_windows_sandbox": { "type": "boolean", "description": "启用 Windows 受限令牌 sandbox。" + }, + "remote_compaction": { + "type": "boolean", + "description": "允许远程(服务端)对话压缩,用于 ChatGPT 登录场景。" + }, + "shell_tool": { + "type": "boolean", + "description": "启用默认 shell 工具。" + }, + "parallel": { + "type": "boolean", + "description": "允许模型并行调用多个工具(需模型支持)。" + }, + "undo": { + "type": "boolean", + "description": "每轮创建可撤销的 ghost commit(undo)。" } } }, @@ -83,6 +94,10 @@ "type": "boolean", "description": "允许 Codex 主动发起内置 Web 搜索。" }, + "web_search_request": { + "type": "boolean", + "description": "同 web_search,别名写法。" + }, "view_image": { "type": "boolean", "description": "允许发送截图/图片作为上下文。" @@ -101,7 +116,7 @@ }, "ignore_default_excludes": { "type": "boolean", - "description": "为 true 时跳过 KEY/SECRET/TOKEN 的默认过滤。" + "description": "为 true 时跳过默认过滤(名称含 KEY/TOKEN 的变量)。" }, "exclude": { "type": "array", @@ -181,7 +196,7 @@ }, "env_http_headers": { "type": "object", - "description": "从环境变量注入的 HTTP 头。", + "description": "从环境变量注入的 HTTP 头,值应为环境变量名。", "additionalProperties": { "type": "string" } @@ -194,6 +209,10 @@ "type": "number", "description": "启动超时时间(秒)。" }, + "startup_timeout_ms": { + "type": "integer", + "description": "启动超时时间(毫秒,startup_timeout_sec 的别名)。" + }, "tool_timeout_sec": { "type": "number", "description": "单个工具调用超时(秒)。" @@ -224,15 +243,10 @@ "enum": ["save-all", "none"], "description": "历史保存策略:save-all 表示全部记录,none 表示禁用持久化。" }, - "file_opener": { - "type": "string", - "enum": ["vscode", "vscode-insiders", "windsurf", "cursor", "none"], - "description": "用于点击引用时打开文件的 URI scheme。" - }, "max_bytes": { - "type": "number", + "type": "integer", "minimum": 0, - "description": "历史文件允许的最大字节数(0 表示使用内置默认限制)。" + "description": "历史文件允许的最大字节数(0/未设置使用内置默认;当前未强制执行)。" } } }, @@ -255,28 +269,9 @@ } ] }, - "hide_agent_reasoning": { - "type": "boolean", - "description": "是否隐藏内部 reasoning 输出。" - }, - "show_raw_agent_reasoning": { + "animations": { "type": "boolean", - "description": "是否展示原始 reasoning 内容。" - }, - "disable_paste_burst": { - "type": "boolean", - "description": "禁用 TUI 内的大段粘贴保护。" - }, - "windows_wsl_setup_acknowledged": { - "type": "boolean", - "description": "标记 Windows/WSL 引导提示已确认。" - }, - "notify": { - "type": "array", - "description": "外部通知程序命令(argv 数组),为空表示禁用。", - "items": { - "type": "string" - } + "description": "启用/禁用欢迎页、闪烁等 TUI 动画。" } } }, @@ -294,6 +289,25 @@ "description": "该 profile 对应的模型提供方。", "x-key-source": "model_providers" }, + "model_reasoning_effort": { + "type": "string", + "enum": ["minimal", "low", "medium", "high"], + "description": "Responses API 的 reasoning effort 级别。" + }, + "model_reasoning_summary": { + "type": "string", + "enum": ["auto", "concise", "detailed", "none"], + "description": "生成 reasoning summary 的粒度/开关。" + }, + "model_verbosity": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Responses API 文本输出详略程度。" + }, + "chatgpt_base_url": { + "type": "string", + "description": "ChatGPT 登录/请求使用的 base URL。" + }, "approval_policy": { "type": "string", "enum": ["untrusted", "on-failure", "on-request", "never"], @@ -303,6 +317,49 @@ "type": "string", "enum": ["read-only", "workspace-write", "danger-full-access"], "description": "该 profile 的 sandbox 模式。" + }, + "experimental_instructions_file": { + "type": "string", + "description": "基础指令文件(实验/兼容字段)。" + }, + "experimental_compact_prompt_file": { + "type": "string", + "description": "历史压缩提示文件(实验/兼容字段)。" + }, + "include_apply_patch_tool": { + "type": "boolean", + "description": "遗留开关:启用 apply_patch 工具。" + }, + "experimental_use_unified_exec_tool": { + "type": "boolean", + "description": "遗留开关:启用统一 exec 工具。" + }, + "experimental_use_rmcp_client": { + "type": "boolean", + "description": "遗留开关:启用实验 RMCP 客户端。" + }, + "experimental_use_freeform_apply_patch": { + "type": "boolean", + "description": "遗留开关:启用自由格式 apply_patch。" + }, + "experimental_sandbox_command_assessment": { + "type": "boolean", + "description": "遗留开关:模型评估 sandbox 命令风险。" + }, + "tools_web_search": { + "type": "boolean", + "description": "遗留开关:允许 web_search 工具。" + }, + "tools_view_image": { + "type": "boolean", + "description": "遗留开关:允许 view_image 工具。" + }, + "features": { + "$ref": "#/$defs/featureFlags" + }, + "oss_provider": { + "type": "string", + "description": "本地 OSS 提供方(如 lmstudio、ollama)。" } } }, @@ -314,6 +371,22 @@ "hide_full_access_warning": { "type": "boolean", "description": "隐藏 danger-full-access 模式下的风险警告。" + }, + "hide_world_writable_warning": { + "type": "boolean", + "description": "隐藏世界可写目录的风险提示。" + }, + "hide_rate_limit_model_nudge": { + "type": "boolean", + "description": "隐藏速率限制模型切换提醒。" + }, + "hide_gpt5_1_migration_prompt": { + "type": "boolean", + "description": "隐藏 GPT-5.1 迁移提示。" + }, + "hide_gpt-5.1-codex-max_migration_prompt": { + "type": "boolean", + "description": "隐藏 gpt-5.1-codex-max 迁移提示。" } } }, @@ -324,8 +397,8 @@ "properties": { "trust_level": { "type": "string", - "enum": ["trusted"], - "description": "信任级别,当前仅支持 trusted。" + "enum": ["trusted", "untrusted"], + "description": "信任级别:trusted / untrusted。" } } }, @@ -383,17 +456,20 @@ "x-key-source": "model_providers" }, "model_context_window": { - "type": "number", + "type": "integer", + "minimum": 0, "description": "模型上下文窗口大小(token)。" }, - "model_max_output_tokens": { - "type": "number", - "description": "模型最大输出 token 数。" - }, "model_auto_compact_token_limit": { - "type": "number", + "type": "integer", + "minimum": 0, "description": "自动触发对话压缩的 token 阈值,0 表示禁用。" }, + "tool_output_token_limit": { + "type": "integer", + "minimum": 0, + "description": "存储单次工具/函数输出的 token 上限。" + }, "model_reasoning_effort": { "type": "string", "enum": ["minimal", "low", "medium", "high"], @@ -409,6 +485,14 @@ "enum": ["low", "medium", "high"], "description": "Responses API 中文本输出的详略程度。" }, + "hide_agent_reasoning": { + "type": "boolean", + "description": "隐藏 AgentReasoning 事件(减少中间思考输出)。" + }, + "show_raw_agent_reasoning": { + "type": "boolean", + "description": "显示原始 reasoning 内容。" + }, "model_supports_reasoning_summaries": { "type": "boolean", "description": "强制认为当前模型支持 reasoning summaries。" @@ -463,9 +547,10 @@ "history": { "$ref": "#/$defs/historySettings" }, - "hide_full_access_warning": { - "type": "boolean", - "description": "已废弃,请改用 [notice].hide_full_access_warning。" + "file_opener": { + "type": "string", + "enum": ["vscode", "vscode-insiders", "windsurf", "cursor", "none"], + "description": "用于点击引用时打开文件的 URI scheme。" }, "notice": { "$ref": "#/$defs/noticeSettings" @@ -489,7 +574,8 @@ "description": "强制登录方式,覆盖自动判断。" }, "project_doc_max_bytes": { - "type": "number", + "type": "integer", + "minimum": 0, "description": "AGENTS.md 读取的最大字节数。" }, "project_doc_fallback_filenames": { @@ -499,6 +585,18 @@ "type": "string" } }, + "check_for_update_on_startup": { + "type": "boolean", + "description": "启动时检查 Codex 更新(默认 true,可在集中管理时关闭提示)。" + }, + "disable_paste_burst": { + "type": "boolean", + "description": "禁用粘贴突发保护;输入不再缓冲/合并。" + }, + "windows_wsl_setup_acknowledged": { + "type": "boolean", + "description": "标记 Windows/WSL 引导提示已确认。" + }, "experimental_use_unified_exec_tool": { "type": "boolean", "description": "旧版实验开关:启用统一 exec 工具。" @@ -529,7 +627,7 @@ }, "notify": { "type": "array", - "description": "Legacy 位置的外部通知命令(建议改用 [tui].notify)。", + "description": "外部通知程序命令(argv 数组);为空表示禁用。", "items": { "type": "string" } @@ -557,6 +655,10 @@ "additionalProperties": true } }, + "oss_provider": { + "type": "string", + "description": "首选本地模型提供方(如 lmstudio、ollama)。" + }, "projects": { "type": "object", "description": "项目路径与信任级别映射。", diff --git a/src-tauri/resources/gemini_cli_settings.schema.json b/src-tauri/resources/gemini_cli_settings.schema.json index 5c7fb0b..b869d96 100644 --- a/src-tauri/resources/gemini_cli_settings.schema.json +++ b/src-tauri/resources/gemini_cli_settings.schema.json @@ -6,6 +6,12 @@ "type": "object", "additionalProperties": false, "properties": { + "$schema": { + "title": "Schema", + "description": "settings.json 使用的 JSON Schema 地址,供编辑器校验和自动补全使用。", + "type": "string", + "default": "https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json" + }, "mcpServers": { "title": "MCP Servers", "description": "MCP 服务器的配置集合。", @@ -23,6 +29,13 @@ "default": {}, "type": "object", "properties": { + "previewFeatures": { + "title": "Preview Features (e.g., models)", + "description": "启用预览特性(如预览模型)。", + "markdownDescription": "Enable preview features (e.g., preview models).\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "preferredEditor": { "title": "Preferred Editor", "description": "打开文件时首选的编辑器。", @@ -268,6 +281,13 @@ "default": false, "type": "boolean" }, + "showModelInfoInChat": { + "title": "Show Model Info In Chat", + "description": "在对话中显示每轮使用的模型名称。", + "markdownDescription": "Show the model name in the chat for each model turn.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "useFullWidth": { "title": "Use Full Width", "description": "输出内容铺满整个终端宽度。", @@ -282,6 +302,13 @@ "default": false, "type": "boolean" }, + "incrementalRendering": { + "title": "Incremental Rendering", + "description": "启用增量渲染以减少闪烁(仅在 useAlternateBuffer 开启时可用,可能出现轻微渲染伪影)。", + "markdownDescription": "Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + }, "customWittyPhrases": { "title": "Custom Witty Phrases", "description": "自定义加载时显示的妙语,提供后会循环使用这些语句而非默认文案。", @@ -697,6 +724,14 @@ "type": "object", "additionalProperties": true }, + "customAliases": { + "title": "Custom Model Config Aliases", + "description": "自定义模型配置别名,会与内置别名合并并可覆盖内置设置。", + "markdownDescription": "Custom named presets for model configs. These are merged with (and override) the built-in aliases.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "additionalProperties": true + }, "overrides": { "title": "Model Config Overrides", "description": "基于匹配结果应用特定配置覆盖,主键为模型或别名,采用匹配度最高的配置。", @@ -832,6 +867,13 @@ "markdownDescription": "Show color in shell output.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `false`", "default": false, "type": "boolean" + }, + "inactivityTimeout": { + "title": "Inactivity Timeout", + "description": "Shell 命令在无输出时允许的最长秒数,默认 300 秒。", + "markdownDescription": "The maximum time in seconds allowed without output from the shell command. Defaults to 5 minutes.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `300`", + "default": 300, + "type": "number" } }, "additionalProperties": false @@ -989,6 +1031,13 @@ "default": false, "type": "boolean" }, + "blockGitExtensions": { + "title": "Blocks extensions from Git", + "description": "阻止从 Git 安装和加载扩展。", + "markdownDescription": "Blocks installing and loading extensions from Git.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "folderTrust": { "title": "Folder Trust", "description": "文件夹信任机制的设置。", @@ -1097,11 +1146,11 @@ "default": false, "type": "boolean" }, - "useModelRouter": { - "title": "Use Model Router", - "description": "启用模型路由,根据复杂度将请求分派到更合适的模型。", - "markdownDescription": "Enable model routing to route requests to the best model based on complexity.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`", - "default": true, + "isModelAvailabilityServiceEnabled": { + "title": "Enable Model Availability Service", + "description": "启用新的模型可用性服务进行模型路由。", + "markdownDescription": "Enable model routing using new availability service.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, "type": "boolean" }, "codebaseInvestigatorSettings": { diff --git a/src-tauri/resources/original-schema/claude-code-schema.json b/src-tauri/resources/original-schema/claude-code-schema.json new file mode 100644 index 0000000..caa63d0 --- /dev/null +++ b/src-tauri/resources/original-schema/claude-code-schema.json @@ -0,0 +1,695 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://json.schemastore.org/claude-code-settings.json", + "$defs": { + "permissionRule": { + "type": "string", + "description": "Tool permission rule", + "pattern": "^((Bash|BashOutput|Edit|ExitPlanMode|Glob|Grep|KillShell|NotebookEdit|Read|SlashCommand|Task|TodoWrite|WebFetch|WebSearch|Write)(\\((?=.*[^)*?])[^)]+\\))?|mcp__.*)$", + "examples": [ + "Bash(git commit:*)", + "Bash(npm run lint:*)", + "Edit(/src/**/*.ts)", + "mcp__github__search_repositories", + "Read(*.env)", + "Read(//Users/alice/secrets/**)", + "Read(~/Documents/*.pdf)", + "WebFetch", + "WebFetch(domain:github.com)", + "Read(//tmp/**/*)" + ] + }, + "hookCommand": { + "anyOf": [ + { + "type": "object", + "description": "Bash command hook", + "additionalProperties": false, + "required": ["type", "command"], + "properties": { + "type": { + "type": "string", + "description": "Hook type", + "const": "command" + }, + "command": { + "type": "string", + "description": "Shell command to execute", + "minLength": 1 + }, + "timeout": { + "type": "number", + "description": "Optional timeout in seconds", + "exclusiveMinimum": 0 + } + } + }, + { + "type": "object", + "description": "LLM prompt hook", + "additionalProperties": false, + "required": ["type", "prompt"], + "properties": { + "type": { + "type": "string", + "description": "Hook type", + "const": "prompt" + }, + "prompt": { + "type": "string", + "description": "Prompt to evaluate with LLM. Use $ARGUMENTS placeholder for hook input JSON.", + "minLength": 1 + }, + "timeout": { + "type": "number", + "description": "Optional timeout in seconds", + "exclusiveMinimum": 0 + } + } + } + ] + }, + "hookMatcher": { + "type": "object", + "description": "Hook matcher configuration with multiple hooks", + "additionalProperties": false, + "required": ["hooks"], + "properties": { + "matcher": { + "type": "string", + "description": "Optional pattern to match tool names, case-sensitive (only applicable for PreToolUse and PostToolUse)" + }, + "hooks": { + "type": "array", + "description": "Array of hooks to execute", + "items": { + "$ref": "#/$defs/hookCommand" + } + } + } + } + }, + "description": "Configuration settings for Claude Code. Learn more: https://docs.claude.com/en/docs/claude-code/settings", + "allowTrailingCommas": true, + "additionalProperties": true, + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference for Claude Code settings" + }, + "apiKeyHelper": { + "type": "string", + "description": "Path to a script that outputs authentication values", + "examples": ["/bin/generate_temp_api_key.sh"], + "minLength": 1 + }, + "awsCredentialExport": { + "type": "string", + "description": "Path to a script that exports AWS credentials", + "examples": ["/bin/generate_aws_grant.sh"], + "minLength": 1 + }, + "awsAuthRefresh": { + "type": "string", + "description": "Path to a script that refreshes AWS authentication", + "examples": ["aws sso login --profile myprofile"], + "minLength": 1 + }, + "cleanupPeriodDays": { + "type": "integer", + "minimum": 0, + "description": "Number of days to retain chat transcripts (0 to disable cleanup)", + "examples": [20, 30, 60], + "default": 30 + }, + "env": { + "type": "object", + "additionalProperties": false, + "description": "Environment variables to set for Claude Code sessions", + "examples": [ + { + "ANTHROPIC_MODEL": "claude-opus-4-1", + "ANTHROPIC_SMALL_FAST_MODEL": "claude-3-5-haiku-latest" + } + ], + "default": {}, + "patternProperties": { + "^[A-Z_][A-Z0-9_]*$": { + "type": "string", + "description": "Environment variable value" + } + } + }, + "includeCoAuthoredBy": { + "type": "boolean", + "description": "Whether to include Claude's co-authored by attribution in commits and PRs (defaults to true)", + "default": true + }, + "permissions": { + "type": "object", + "properties": { + "allow": { + "type": "array", + "items": { + "$ref": "#/$defs/permissionRule" + }, + "description": "List of permission rules for allowed operations", + "uniqueItems": true + }, + "deny": { + "type": "array", + "items": { + "$ref": "#/$defs/permissionRule" + }, + "description": "List of permission rules for denied operations", + "uniqueItems": true + }, + "ask": { + "type": "array", + "items": { + "$ref": "#/$defs/permissionRule" + }, + "description": "List of permission rules that should always prompt for confirmation", + "uniqueItems": true + }, + "defaultMode": { + "type": "string", + "enum": ["acceptEdits", "bypassPermissions", "default", "plan"], + "description": "Default permission mode when Claude Code needs access" + }, + "disableBypassPermissionsMode": { + "type": "string", + "enum": ["disable"], + "description": "Disable the ability to bypass permission prompts" + }, + "additionalDirectories": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Additional directories to include in the permission scope", + "examples": [["//Users/alice/Documents", "~/projects"]], + "uniqueItems": true + } + }, + "additionalProperties": false, + "description": "Tool usage permissions configuration", + "examples": [ + { + "allow": ["Bash(git add:*)"], + "ask": ["Bash(gh pr create:*)", "Bash(git commit:*)"], + "deny": ["Read(*.env)", "Bash(rm:*)", "Bash(curl:*)"], + "defaultMode": "default" + } + ] + }, + "model": { + "type": "string", + "description": "Override the default model used by Claude Code" + }, + "enableAllProjectMcpServers": { + "type": "boolean", + "description": "Whether to automatically approve all MCP servers in the project", + "examples": [true] + }, + "enabledMcpjsonServers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "List of approved MCP servers from .mcp.json", + "examples": [["memory", "github"]] + }, + "disabledMcpjsonServers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "List of rejected MCP servers from .mcp.json", + "examples": [["filesystem"]] + }, + "allowedMcpServers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "serverName": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "description": "Name of the MCP server that users are allowed to configure" + } + }, + "required": ["serverName"], + "additionalProperties": false + }, + "description": "Enterprise allowlist of MCP servers that can be used. Applies to all scopes including enterprise servers from managed-mcp.json. If undefined, all servers are allowed. If empty array, no servers are allowed. Denylist takes precedence - if a server is on both lists, it is denied." + }, + "deniedMcpServers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "serverName": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "description": "Name of the MCP server that is explicitly blocked" + } + }, + "required": ["serverName"], + "additionalProperties": false + }, + "description": "Enterprise denylist of MCP servers that are explicitly blocked. If a server is on the denylist, it will be blocked across all scopes including enterprise. Denylist takes precedence over allowlist - if a server is on both lists, it is denied." + }, + "hooks": { + "type": "object", + "additionalProperties": false, + "description": "Custom commands to run before/after tool executions", + "examples": [ + { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "prettier --write", + "timeout": 5 + } + ] + } + ] + } + ], + "properties": { + "PreToolUse": { + "type": "array", + "description": "Hooks that run before tool calls", + "items": { + "$ref": "#/$defs/hookMatcher" + } + }, + "PostToolUse": { + "type": "array", + "description": "Hooks that run after tool completion", + "items": { + "$ref": "#/$defs/hookMatcher" + } + }, + "Notification": { + "type": "array", + "description": "Hooks that trigger on notifications", + "items": { + "$ref": "#/$defs/hookMatcher" + } + }, + "UserPromptSubmit": { + "type": "array", + "description": "Hooks that run when a user submits a prompt", + "items": { + "$ref": "#/$defs/hookMatcher" + } + }, + "Stop": { + "type": "array", + "description": "Hooks that run when agents finish responding", + "items": { + "$ref": "#/$defs/hookMatcher" + } + }, + "SubagentStop": { + "type": "array", + "description": "Hooks that run when subagents finish responding", + "items": { + "$ref": "#/$defs/hookMatcher" + } + }, + "PreCompact": { + "type": "array", + "description": "Hooks that run before the context is compacted", + "items": { + "$ref": "#/$defs/hookMatcher" + } + }, + "SessionStart": { + "type": "array", + "description": "Hooks that run when a new session starts", + "items": { + "$ref": "#/$defs/hookMatcher" + } + }, + "SessionEnd": { + "type": "array", + "description": "Hooks that run when a session ends", + "items": { + "$ref": "#/$defs/hookMatcher" + } + } + } + }, + "disableAllHooks": { + "type": "boolean", + "description": "Disable all hooks and statusLine execution" + }, + "statusLine": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "command" + }, + "command": { + "type": "string" + }, + "padding": { + "type": "number" + } + }, + "required": ["type", "command"], + "additionalProperties": false, + "description": "Custom status line display configuration", + "examples": [ + { + "type": "command", + "command": "~/.claude/statusline.sh" + } + ] + }, + "enabledPlugins": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + }, + { + "not": {} + } + ] + }, + "description": "Enabled plugins using plugin-id@marketplace-id format. Example: { \"formatter@anthropic-tools\": true }. Also supports extended format with version constraints." + }, + "extraKnownMarketplaces": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "source": { + "anyOf": [ + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "url" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Direct URL to marketplace.json file" + } + }, + "required": ["source", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "github" + }, + "repo": { + "type": "string", + "description": "GitHub repository in owner/repo format" + }, + "ref": { + "type": "string", + "description": "Git branch or tag to use (e.g., \"main\", \"v1.0.0\"). Defaults to repository default branch." + }, + "path": { + "type": "string", + "description": "Path to marketplace.json within repo (defaults to .claude-plugin/marketplace.json)" + } + }, + "required": ["source", "repo"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "git" + }, + "url": { + "type": "string", + "pattern": "\\.git$", + "description": "Full git repository URL" + }, + "ref": { + "type": "string", + "description": "Git branch or tag to use (e.g., \"main\", \"v1.0.0\"). Defaults to repository default branch." + }, + "path": { + "type": "string", + "description": "Path to marketplace.json within repo (defaults to .claude-plugin/marketplace.json)" + } + }, + "required": ["source", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "npm" + }, + "package": { + "type": "string", + "description": "NPM package containing marketplace.json" + } + }, + "required": ["source", "package"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "file" + }, + "path": { + "type": "string", + "description": "Local file path to marketplace.json" + } + }, + "required": ["source", "path"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "directory" + }, + "path": { + "type": "string", + "description": "Local directory containing .claude-plugin/marketplace.json" + } + }, + "required": ["source", "path"], + "additionalProperties": false + } + ], + "description": "Where to fetch the marketplace from" + }, + "installLocation": { + "type": "string", + "description": "Local cache path where marketplace manifest is stored (auto-generated if not provided)" + } + }, + "required": ["source"], + "additionalProperties": false + }, + "description": "Additional marketplaces to make available for this repository. Typically used in repository .claude/settings.json to ensure team members have required plugin sources." + }, + "skippedMarketplaces": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "List of marketplace names the user has chosen not to install when prompted" + }, + "skippedPlugins": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "List of plugin IDs (plugin@marketplace format) the user has chosen not to install when prompted" + }, + "forceLoginMethod": { + "type": "string", + "enum": ["claudeai", "console"], + "description": "Force a specific login method: \"claudeai\" for Claude Pro/Max, \"console\" for Console billing", + "examples": ["claudeai"] + }, + "forceLoginOrgUUID": { + "type": "string", + "description": "Organization UUID to use for OAuth login", + "examples": ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"], + "minLength": 1 + }, + "otelHeadersHelper": { + "type": "string", + "description": "Path to a script that outputs OpenTelemetry headers", + "minLength": 1 + }, + "outputStyle": { + "type": "string", + "description": "Controls the output style for assistant responses", + "examples": ["default", "Explanatory", "Learning"], + "minLength": 1 + }, + "skipWebFetchPreflight": { + "type": "boolean", + "description": "Skip the WebFetch blocklist check for enterprise environments with restrictive security policies" + }, + "sandbox": { + "type": "object", + "properties": { + "network": { + "type": "object", + "properties": { + "allowUnixSockets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Allow Unix domain sockets for local IPC (SSH agent, Docker, etc.). Provide an array of specific paths. Defaults to blocking if not specified" + }, + "allowLocalBinding": { + "type": "boolean", + "description": "Allow binding to local network addresses (e.g., localhost ports). Defaults to false if not specified" + }, + "httpProxyPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "HTTP proxy port to use for network filtering. If not specified, a proxy server will be started automatically" + }, + "socksProxyPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "SOCKS proxy port to use for network filtering. If not specified, a proxy server will be started automatically" + } + }, + "additionalProperties": false + }, + "ignoreViolations": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of filesystem paths to ignore sandbox violations for when this command pattern matches" + }, + "description": "Map of command patterns to filesystem paths to ignore violations for. Use \"*\" to match all commands" + }, + "excludedCommands": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Commands that should never run in the sandbox (e.g., [\"git\", \"docker\"])" + }, + "autoAllowBashIfSandboxed": { + "type": "boolean", + "description": "Automatically allow bash commands without prompting when they run in the sandbox. Only applies to commands that will run sandboxed." + }, + "enableWeakerNestedSandbox": { + "type": "boolean", + "description": "Enable weaker sandbox mode for unprivileged docker environments where --proc mounting fails. This significantly reduces the strength of the sandbox and should only be used when this risk is acceptable.Default: false (secure)." + }, + "allowUnsandboxedCommands": { + "type": "boolean", + "description": "Allow commands to run outside the sandbox via the dangerouslyDisableSandbox parameter. When false, the dangerouslyDisableSandbox parameter is completely ignored and all commands must run sandboxed. Default: true." + }, + "enabled": { + "type": "boolean", + "description": "Enable sandboxed bash" + } + }, + "additionalProperties": false + }, + "spinnerTipsEnabled": { + "type": "boolean", + "description": "Whether to show tips in the spinner" + }, + "alwaysThinkingEnabled": { + "type": "boolean", + "description": "Whether extended thinking is always enabled (default: false)" + }, + "companyAnnouncements": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Company announcements to display at startup (one will be randomly selected if multiple are provided)" + }, + "pluginConfigs": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "mcpServers": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "description": "User configuration values for MCP servers keyed by server name" + } + }, + "additionalProperties": false + }, + "description": "Per-plugin configuration including MCP server user configs, keyed by plugin ID (plugin@marketplace format)" + } + }, + "title": "Claude Code Settings" +} diff --git a/src-tauri/resources/original-schema/gemini-cli-schema.json b/src-tauri/resources/original-schema/gemini-cli-schema.json new file mode 100644 index 0000000..69cda4a --- /dev/null +++ b/src-tauri/resources/original-schema/gemini-cli-schema.json @@ -0,0 +1,1707 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json", + "title": "Gemini CLI Settings", + "description": "Configuration file schema for Gemini CLI settings. This schema enables IDE completion for `settings.json`.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "title": "Schema", + "description": "The URL of the JSON schema for this settings file. Used by editors for validation and autocompletion.", + "type": "string", + "default": "https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json" + }, + "mcpServers": { + "title": "MCP Servers", + "description": "Configuration for MCP servers.", + "markdownDescription": "Configuration for MCP servers.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/MCPServerConfig" + } + }, + "general": { + "title": "General", + "description": "General application settings.", + "markdownDescription": "General application settings.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "previewFeatures": { + "title": "Preview Features (e.g., models)", + "description": "Enable preview features (e.g., preview models).", + "markdownDescription": "Enable preview features (e.g., preview models).\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "preferredEditor": { + "title": "Preferred Editor", + "description": "The preferred editor to open files in.", + "markdownDescription": "The preferred editor to open files in.\n\n- Category: `General`\n- Requires restart: `no`", + "type": "string" + }, + "vimMode": { + "title": "Vim Mode", + "description": "Enable Vim keybindings", + "markdownDescription": "Enable Vim keybindings\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "disableAutoUpdate": { + "title": "Disable Auto Update", + "description": "Disable automatic updates", + "markdownDescription": "Disable automatic updates\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "disableUpdateNag": { + "title": "Disable Update Nag", + "description": "Disable update notification prompts.", + "markdownDescription": "Disable update notification prompts.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "checkpointing": { + "title": "Checkpointing", + "description": "Session checkpointing settings.", + "markdownDescription": "Session checkpointing settings.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "enabled": { + "title": "Enable Checkpointing", + "description": "Enable session checkpointing for recovery", + "markdownDescription": "Enable session checkpointing for recovery\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "enablePromptCompletion": { + "title": "Enable Prompt Completion", + "description": "Enable AI-powered prompt completion suggestions while typing.", + "markdownDescription": "Enable AI-powered prompt completion suggestions while typing.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "retryFetchErrors": { + "title": "Retry Fetch Errors", + "description": "Retry on \"exception TypeError: fetch failed sending request\" errors.", + "markdownDescription": "Retry on \"exception TypeError: fetch failed sending request\" errors.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "debugKeystrokeLogging": { + "title": "Debug Keystroke Logging", + "description": "Enable debug logging of keystrokes to the console.", + "markdownDescription": "Enable debug logging of keystrokes to the console.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "sessionRetention": { + "title": "Session Retention", + "description": "Settings for automatic session cleanup.", + "markdownDescription": "Settings for automatic session cleanup.\n\n- Category: `General`\n- Requires restart: `no`", + "type": "object", + "properties": { + "enabled": { + "title": "Enable Session Cleanup", + "description": "Enable automatic session cleanup", + "markdownDescription": "Enable automatic session cleanup\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "maxAge": { + "title": "Max Session Age", + "description": "Maximum age of sessions to keep (e.g., \"30d\", \"7d\", \"24h\", \"1w\")", + "markdownDescription": "Maximum age of sessions to keep (e.g., \"30d\", \"7d\", \"24h\", \"1w\")\n\n- Category: `General`\n- Requires restart: `no`", + "type": "string" + }, + "maxCount": { + "title": "Max Session Count", + "description": "Alternative: Maximum number of sessions to keep (most recent)", + "markdownDescription": "Alternative: Maximum number of sessions to keep (most recent)\n\n- Category: `General`\n- Requires restart: `no`", + "type": "number" + }, + "minRetention": { + "title": "Min Retention Period", + "description": "Minimum retention period (safety limit, defaults to \"1d\")", + "markdownDescription": "Minimum retention period (safety limit, defaults to \"1d\")\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `1d`", + "default": "1d", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "output": { + "title": "Output", + "description": "Settings for the CLI output.", + "markdownDescription": "Settings for the CLI output.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "format": { + "title": "Output Format", + "description": "The format of the CLI output.", + "markdownDescription": "The format of the CLI output.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `text`", + "default": "text", + "type": "string", + "enum": ["text", "json"] + } + }, + "additionalProperties": false + }, + "ui": { + "title": "UI", + "description": "User interface settings.", + "markdownDescription": "User interface settings.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "theme": { + "title": "Theme", + "description": "The color theme for the UI. See the CLI themes guide for available options.", + "markdownDescription": "The color theme for the UI. See the CLI themes guide for available options.\n\n- Category: `UI`\n- Requires restart: `no`", + "type": "string" + }, + "customThemes": { + "title": "Custom Themes", + "description": "Custom theme definitions.", + "markdownDescription": "Custom theme definitions.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/CustomTheme" + } + }, + "hideWindowTitle": { + "title": "Hide Window Title", + "description": "Hide the window title bar", + "markdownDescription": "Hide the window title bar\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "showStatusInTitle": { + "title": "Show Status in Title", + "description": "Show Gemini CLI status and thoughts in the terminal window title", + "markdownDescription": "Show Gemini CLI status and thoughts in the terminal window title\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "hideTips": { + "title": "Hide Tips", + "description": "Hide helpful tips in the UI", + "markdownDescription": "Hide helpful tips in the UI\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "hideBanner": { + "title": "Hide Banner", + "description": "Hide the application banner", + "markdownDescription": "Hide the application banner\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "hideContextSummary": { + "title": "Hide Context Summary", + "description": "Hide the context summary (GEMINI.md, MCP servers) above the input.", + "markdownDescription": "Hide the context summary (GEMINI.md, MCP servers) above the input.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "footer": { + "title": "Footer", + "description": "Settings for the footer.", + "markdownDescription": "Settings for the footer.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "hideCWD": { + "title": "Hide CWD", + "description": "Hide the current working directory path in the footer.", + "markdownDescription": "Hide the current working directory path in the footer.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "hideSandboxStatus": { + "title": "Hide Sandbox Status", + "description": "Hide the sandbox status indicator in the footer.", + "markdownDescription": "Hide the sandbox status indicator in the footer.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "hideModelInfo": { + "title": "Hide Model Info", + "description": "Hide the model name and context usage in the footer.", + "markdownDescription": "Hide the model name and context usage in the footer.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "hideContextPercentage": { + "title": "Hide Context Window Percentage", + "description": "Hides the context window remaining percentage.", + "markdownDescription": "Hides the context window remaining percentage.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "hideFooter": { + "title": "Hide Footer", + "description": "Hide the footer from the UI", + "markdownDescription": "Hide the footer from the UI\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "showMemoryUsage": { + "title": "Show Memory Usage", + "description": "Display memory usage information in the UI", + "markdownDescription": "Display memory usage information in the UI\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "showLineNumbers": { + "title": "Show Line Numbers", + "description": "Show line numbers in the chat.", + "markdownDescription": "Show line numbers in the chat.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "showCitations": { + "title": "Show Citations", + "description": "Show citations for generated text in the chat.", + "markdownDescription": "Show citations for generated text in the chat.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "showModelInfoInChat": { + "title": "Show Model Info In Chat", + "description": "Show the model name in the chat for each model turn.", + "markdownDescription": "Show the model name in the chat for each model turn.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "useFullWidth": { + "title": "Use Full Width", + "description": "Use the entire width of the terminal for output.", + "markdownDescription": "Use the entire width of the terminal for output.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "useAlternateBuffer": { + "title": "Use Alternate Screen Buffer", + "description": "Use an alternate screen buffer for the UI, preserving shell history.", + "markdownDescription": "Use an alternate screen buffer for the UI, preserving shell history.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "incrementalRendering": { + "title": "Incremental Rendering", + "description": "Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.", + "markdownDescription": "Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "customWittyPhrases": { + "title": "Custom Witty Phrases", + "description": "Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults.", + "markdownDescription": "Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "accessibility": { + "title": "Accessibility", + "description": "Accessibility settings.", + "markdownDescription": "Accessibility settings.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "disableLoadingPhrases": { + "title": "Disable Loading Phrases", + "description": "Disable loading phrases for accessibility", + "markdownDescription": "Disable loading phrases for accessibility\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "screenReader": { + "title": "Screen Reader Mode", + "description": "Render output in plain-text to be more screen reader accessible", + "markdownDescription": "Render output in plain-text to be more screen reader accessible\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "ide": { + "title": "IDE", + "description": "IDE integration settings.", + "markdownDescription": "IDE integration settings.\n\n- Category: `IDE`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "enabled": { + "title": "IDE Mode", + "description": "Enable IDE integration mode", + "markdownDescription": "Enable IDE integration mode\n\n- Category: `IDE`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "hasSeenNudge": { + "title": "Has Seen IDE Integration Nudge", + "description": "Whether the user has seen the IDE integration nudge.", + "markdownDescription": "Whether the user has seen the IDE integration nudge.\n\n- Category: `IDE`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "privacy": { + "title": "Privacy", + "description": "Privacy-related settings.", + "markdownDescription": "Privacy-related settings.\n\n- Category: `Privacy`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "usageStatisticsEnabled": { + "title": "Enable Usage Statistics", + "description": "Enable collection of usage statistics", + "markdownDescription": "Enable collection of usage statistics\n\n- Category: `Privacy`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "telemetry": { + "title": "Telemetry", + "description": "Telemetry configuration.", + "markdownDescription": "Telemetry configuration.\n\n- Category: `Advanced`\n- Requires restart: `yes`", + "$ref": "#/$defs/TelemetrySettings" + }, + "model": { + "title": "Model", + "description": "Settings related to the generative model.", + "markdownDescription": "Settings related to the generative model.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "name": { + "title": "Model", + "description": "The Gemini model to use for conversations.", + "markdownDescription": "The Gemini model to use for conversations.\n\n- Category: `Model`\n- Requires restart: `no`", + "type": "string" + }, + "maxSessionTurns": { + "title": "Max Session Turns", + "description": "Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.", + "markdownDescription": "Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `-1`", + "default": -1, + "type": "number" + }, + "summarizeToolOutput": { + "title": "Summarize Tool Output", + "description": "Enables or disables summarization of tool output. Configure per-tool token budgets (for example {\"run_shell_command\": {\"tokenBudget\": 2000}}). Currently only the run_shell_command tool supports summarization.", + "markdownDescription": "Enables or disables summarization of tool output. Configure per-tool token budgets (for example {\"run_shell_command\": {\"tokenBudget\": 2000}}). Currently only the run_shell_command tool supports summarization.\n\n- Category: `Model`\n- Requires restart: `no`", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SummarizeToolOutputSettings" + } + }, + "compressionThreshold": { + "title": "Compression Threshold", + "description": "The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).", + "markdownDescription": "The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).\n\n- Category: `Model`\n- Requires restart: `yes`\n- Default: `0.5`", + "default": 0.5, + "type": "number" + }, + "skipNextSpeakerCheck": { + "title": "Skip Next Speaker Check", + "description": "Skip the next speaker check.", + "markdownDescription": "Skip the next speaker check.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `true`", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "modelConfigs": { + "title": "Model Configs", + "description": "Model configurations.", + "markdownDescription": "Model configurations.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"aliases\": {\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n }\n }\n}`", + "default": { + "aliases": { + "base": { + "modelConfig": { + "generateContentConfig": { + "temperature": 0, + "topP": 1 + } + } + }, + "chat-base": { + "extends": "base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "includeThoughts": true + }, + "temperature": 1, + "topP": 0.95, + "topK": 64 + } + } + }, + "chat-base-2.5": { + "extends": "chat-base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "thinkingBudget": 8192 + } + } + } + }, + "chat-base-3": { + "extends": "chat-base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "thinkingLevel": "HIGH" + } + } + } + }, + "gemini-3-pro-preview": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemini-3-pro-preview" + } + }, + "gemini-2.5-pro": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-pro" + } + }, + "gemini-2.5-flash": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-flash" + } + }, + "gemini-2.5-flash-lite": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-flash-lite" + } + }, + "gemini-2.5-flash-base": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash" + } + }, + "classifier": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "maxOutputTokens": 1024, + "thinkingConfig": { + "thinkingBudget": 512 + } + } + } + }, + "prompt-completion": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "temperature": 0.3, + "maxOutputTokens": 16000, + "thinkingConfig": { + "thinkingBudget": 0 + } + } + } + }, + "edit-corrector": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "thinkingConfig": { + "thinkingBudget": 0 + } + } + } + }, + "summarizer-default": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "maxOutputTokens": 2000 + } + } + }, + "summarizer-shell": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "maxOutputTokens": 2000 + } + } + }, + "web-search": { + "extends": "gemini-2.5-flash-base", + "modelConfig": { + "generateContentConfig": { + "tools": [ + { + "googleSearch": {} + } + ] + } + } + }, + "web-fetch": { + "extends": "gemini-2.5-flash-base", + "modelConfig": { + "generateContentConfig": { + "tools": [ + { + "urlContext": {} + } + ] + } + } + }, + "web-fetch-fallback": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + }, + "loop-detection": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + }, + "loop-detection-double-check": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-pro" + } + }, + "llm-edit-fixer": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + }, + "next-speaker-checker": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + }, + "chat-compression-3-pro": { + "modelConfig": { + "model": "gemini-3-pro-preview" + } + }, + "chat-compression-2.5-pro": { + "modelConfig": { + "model": "gemini-2.5-pro" + } + }, + "chat-compression-2.5-flash": { + "modelConfig": { + "model": "gemini-2.5-flash" + } + }, + "chat-compression-2.5-flash-lite": { + "modelConfig": { + "model": "gemini-2.5-flash-lite" + } + }, + "chat-compression-default": { + "modelConfig": { + "model": "gemini-2.5-pro" + } + } + } + }, + "type": "object", + "properties": { + "aliases": { + "title": "Model Config Aliases", + "description": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.", + "markdownDescription": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-2.5-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n }\n}`", + "default": { + "base": { + "modelConfig": { + "generateContentConfig": { + "temperature": 0, + "topP": 1 + } + } + }, + "chat-base": { + "extends": "base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "includeThoughts": true + }, + "temperature": 1, + "topP": 0.95, + "topK": 64 + } + } + }, + "chat-base-2.5": { + "extends": "chat-base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "thinkingBudget": 8192 + } + } + } + }, + "chat-base-3": { + "extends": "chat-base", + "modelConfig": { + "generateContentConfig": { + "thinkingConfig": { + "thinkingLevel": "HIGH" + } + } + } + }, + "gemini-3-pro-preview": { + "extends": "chat-base-3", + "modelConfig": { + "model": "gemini-3-pro-preview" + } + }, + "gemini-2.5-pro": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-pro" + } + }, + "gemini-2.5-flash": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-flash" + } + }, + "gemini-2.5-flash-lite": { + "extends": "chat-base-2.5", + "modelConfig": { + "model": "gemini-2.5-flash-lite" + } + }, + "gemini-2.5-flash-base": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash" + } + }, + "classifier": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "maxOutputTokens": 1024, + "thinkingConfig": { + "thinkingBudget": 512 + } + } + } + }, + "prompt-completion": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "temperature": 0.3, + "maxOutputTokens": 16000, + "thinkingConfig": { + "thinkingBudget": 0 + } + } + } + }, + "edit-corrector": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "thinkingConfig": { + "thinkingBudget": 0 + } + } + } + }, + "summarizer-default": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "maxOutputTokens": 2000 + } + } + }, + "summarizer-shell": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-flash-lite", + "generateContentConfig": { + "maxOutputTokens": 2000 + } + } + }, + "web-search": { + "extends": "gemini-2.5-flash-base", + "modelConfig": { + "generateContentConfig": { + "tools": [ + { + "googleSearch": {} + } + ] + } + } + }, + "web-fetch": { + "extends": "gemini-2.5-flash-base", + "modelConfig": { + "generateContentConfig": { + "tools": [ + { + "urlContext": {} + } + ] + } + } + }, + "web-fetch-fallback": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + }, + "loop-detection": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + }, + "loop-detection-double-check": { + "extends": "base", + "modelConfig": { + "model": "gemini-2.5-pro" + } + }, + "llm-edit-fixer": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + }, + "next-speaker-checker": { + "extends": "gemini-2.5-flash-base", + "modelConfig": {} + }, + "chat-compression-3-pro": { + "modelConfig": { + "model": "gemini-3-pro-preview" + } + }, + "chat-compression-2.5-pro": { + "modelConfig": { + "model": "gemini-2.5-pro" + } + }, + "chat-compression-2.5-flash": { + "modelConfig": { + "model": "gemini-2.5-flash" + } + }, + "chat-compression-2.5-flash-lite": { + "modelConfig": { + "model": "gemini-2.5-flash-lite" + } + }, + "chat-compression-default": { + "modelConfig": { + "model": "gemini-2.5-pro" + } + } + }, + "type": "object", + "additionalProperties": true + }, + "customAliases": { + "title": "Custom Model Config Aliases", + "description": "Custom named presets for model configs. These are merged with (and override) the built-in aliases.", + "markdownDescription": "Custom named presets for model configs. These are merged with (and override) the built-in aliases.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "additionalProperties": true + }, + "overrides": { + "title": "Model Config Overrides", + "description": "Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.", + "markdownDescription": "Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "type": "array", + "items": {} + } + }, + "additionalProperties": false + }, + "context": { + "title": "Context", + "description": "Settings for managing context provided to the model.", + "markdownDescription": "Settings for managing context provided to the model.\n\n- Category: `Context`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "fileName": { + "title": "Context File Name", + "description": "The name of the context file or files to load into memory. Accepts either a single string or an array of strings.", + "markdownDescription": "The name of the context file or files to load into memory. Accepts either a single string or an array of strings.\n\n- Category: `Context`\n- Requires restart: `no`", + "$ref": "#/$defs/StringOrStringArray" + }, + "importFormat": { + "title": "Memory Import Format", + "description": "The format to use when importing memory.", + "markdownDescription": "The format to use when importing memory.\n\n- Category: `Context`\n- Requires restart: `no`", + "type": "string" + }, + "discoveryMaxDirs": { + "title": "Memory Discovery Max Dirs", + "description": "Maximum number of directories to search for memory.", + "markdownDescription": "Maximum number of directories to search for memory.\n\n- Category: `Context`\n- Requires restart: `no`\n- Default: `200`", + "default": 200, + "type": "number" + }, + "includeDirectories": { + "title": "Include Directories", + "description": "Additional directories to include in the workspace context. Missing directories will be skipped with a warning.", + "markdownDescription": "Additional directories to include in the workspace context. Missing directories will be skipped with a warning.\n\n- Category: `Context`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "loadMemoryFromIncludeDirectories": { + "title": "Load Memory From Include Directories", + "description": "Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used.", + "markdownDescription": "Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used.\n\n- Category: `Context`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "fileFiltering": { + "title": "File Filtering", + "description": "Settings for git-aware file filtering.", + "markdownDescription": "Settings for git-aware file filtering.\n\n- Category: `Context`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "respectGitIgnore": { + "title": "Respect .gitignore", + "description": "Respect .gitignore files when searching", + "markdownDescription": "Respect .gitignore files when searching\n\n- Category: `Context`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "respectGeminiIgnore": { + "title": "Respect .geminiignore", + "description": "Respect .geminiignore files when searching", + "markdownDescription": "Respect .geminiignore files when searching\n\n- Category: `Context`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "enableRecursiveFileSearch": { + "title": "Enable Recursive File Search", + "description": "Enable recursive file search functionality when completing @ references in the prompt.", + "markdownDescription": "Enable recursive file search functionality when completing @ references in the prompt.\n\n- Category: `Context`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "disableFuzzySearch": { + "title": "Disable Fuzzy Search", + "description": "Disable fuzzy search when searching for files.", + "markdownDescription": "Disable fuzzy search when searching for files.\n\n- Category: `Context`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "tools": { + "title": "Tools", + "description": "Settings for built-in and custom tools.", + "markdownDescription": "Settings for built-in and custom tools.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "sandbox": { + "title": "Sandbox", + "description": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile.", + "markdownDescription": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile.\n\n- Category: `Tools`\n- Requires restart: `yes`", + "$ref": "#/$defs/BooleanOrString" + }, + "shell": { + "title": "Shell", + "description": "Settings for shell execution.", + "markdownDescription": "Settings for shell execution.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "enableInteractiveShell": { + "title": "Enable Interactive Shell", + "description": "Use node-pty for an interactive shell experience. Fallback to child_process still applies.", + "markdownDescription": "Use node-pty for an interactive shell experience. Fallback to child_process still applies.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "pager": { + "title": "Pager", + "description": "The pager command to use for shell output. Defaults to `cat`.", + "markdownDescription": "The pager command to use for shell output. Defaults to `cat`.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `cat`", + "default": "cat", + "type": "string" + }, + "showColor": { + "title": "Show Color", + "description": "Show color in shell output.", + "markdownDescription": "Show color in shell output.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "inactivityTimeout": { + "title": "Inactivity Timeout", + "description": "The maximum time in seconds allowed without output from the shell command. Defaults to 5 minutes.", + "markdownDescription": "The maximum time in seconds allowed without output from the shell command. Defaults to 5 minutes.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `300`", + "default": 300, + "type": "number" + } + }, + "additionalProperties": false + }, + "autoAccept": { + "title": "Auto Accept", + "description": "Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).", + "markdownDescription": "Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "core": { + "title": "Core Tools", + "description": "Restrict the set of built-in tools with an allowlist. Match semantics mirror tools.allowed; see the built-in tools documentation for available names.", + "markdownDescription": "Restrict the set of built-in tools with an allowlist. Match semantics mirror tools.allowed; see the built-in tools documentation for available names.\n\n- Category: `Tools`\n- Requires restart: `yes`", + "type": "array", + "items": { + "type": "string" + } + }, + "allowed": { + "title": "Allowed Tools", + "description": "Tool names that bypass the confirmation dialog. Useful for trusted commands (for example [\"run_shell_command(git)\", \"run_shell_command(npm test)\"]). See shell tool command restrictions for matching details.", + "markdownDescription": "Tool names that bypass the confirmation dialog. Useful for trusted commands (for example [\"run_shell_command(git)\", \"run_shell_command(npm test)\"]). See shell tool command restrictions for matching details.\n\n- Category: `Advanced`\n- Requires restart: `yes`", + "type": "array", + "items": { + "type": "string" + } + }, + "exclude": { + "title": "Exclude Tools", + "description": "Tool names to exclude from discovery.", + "markdownDescription": "Tool names to exclude from discovery.\n\n- Category: `Tools`\n- Requires restart: `yes`", + "type": "array", + "items": { + "type": "string" + } + }, + "discoveryCommand": { + "title": "Tool Discovery Command", + "description": "Command to run for tool discovery.", + "markdownDescription": "Command to run for tool discovery.\n\n- Category: `Tools`\n- Requires restart: `yes`", + "type": "string" + }, + "callCommand": { + "title": "Tool Call Command", + "description": "Defines a custom shell command for invoking discovered tools. The command must take the tool name as the first argument, read JSON arguments from stdin, and emit JSON results on stdout.", + "markdownDescription": "Defines a custom shell command for invoking discovered tools. The command must take the tool name as the first argument, read JSON arguments from stdin, and emit JSON results on stdout.\n\n- Category: `Tools`\n- Requires restart: `yes`", + "type": "string" + }, + "useRipgrep": { + "title": "Use Ripgrep", + "description": "Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.", + "markdownDescription": "Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "enableToolOutputTruncation": { + "title": "Enable Tool Output Truncation", + "description": "Enable truncation of large tool outputs.", + "markdownDescription": "Enable truncation of large tool outputs.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "truncateToolOutputThreshold": { + "title": "Tool Output Truncation Threshold", + "description": "Truncate tool output if it is larger than this many characters. Set to -1 to disable.", + "markdownDescription": "Truncate tool output if it is larger than this many characters. Set to -1 to disable.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `4000000`", + "default": 4000000, + "type": "number" + }, + "truncateToolOutputLines": { + "title": "Tool Output Truncation Lines", + "description": "The number of lines to keep when truncating tool output.", + "markdownDescription": "The number of lines to keep when truncating tool output.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `1000`", + "default": 1000, + "type": "number" + }, + "enableMessageBusIntegration": { + "title": "Enable Message Bus Integration", + "description": "Enable policy-based tool confirmation via message bus integration. When enabled, tools automatically respect policy engine decisions (ALLOW/DENY/ASK_USER) without requiring individual tool implementations.", + "markdownDescription": "Enable policy-based tool confirmation via message bus integration. When enabled, tools automatically respect policy engine decisions (ALLOW/DENY/ASK_USER) without requiring individual tool implementations.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "enableHooks": { + "title": "Enable Hooks System", + "description": "Enable the hooks system for intercepting and customizing Gemini CLI behavior. When enabled, hooks configured in settings will execute at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.). Requires MessageBus integration.", + "markdownDescription": "Enable the hooks system for intercepting and customizing Gemini CLI behavior. When enabled, hooks configured in settings will execute at appropriate lifecycle events (BeforeTool, AfterTool, BeforeModel, etc.). Requires MessageBus integration.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "mcp": { + "title": "MCP", + "description": "Settings for Model Context Protocol (MCP) servers.", + "markdownDescription": "Settings for Model Context Protocol (MCP) servers.\n\n- Category: `MCP`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "serverCommand": { + "title": "MCP Server Command", + "description": "Command to start an MCP server.", + "markdownDescription": "Command to start an MCP server.\n\n- Category: `MCP`\n- Requires restart: `yes`", + "type": "string" + }, + "allowed": { + "title": "Allow MCP Servers", + "description": "A list of MCP servers to allow.", + "markdownDescription": "A list of MCP servers to allow.\n\n- Category: `MCP`\n- Requires restart: `yes`", + "type": "array", + "items": { + "type": "string" + } + }, + "excluded": { + "title": "Exclude MCP Servers", + "description": "A list of MCP servers to exclude.", + "markdownDescription": "A list of MCP servers to exclude.\n\n- Category: `MCP`\n- Requires restart: `yes`", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "useSmartEdit": { + "title": "Use Smart Edit", + "description": "Enable the smart-edit tool instead of the replace tool.", + "markdownDescription": "Enable the smart-edit tool instead of the replace tool.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "useWriteTodos": { + "title": "Use WriteTodos", + "description": "Enable the write_todos tool.", + "markdownDescription": "Enable the write_todos tool.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "security": { + "title": "Security", + "description": "Security-related settings.", + "markdownDescription": "Security-related settings.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "disableYoloMode": { + "title": "Disable YOLO Mode", + "description": "Disable YOLO mode, even if enabled by a flag.", + "markdownDescription": "Disable YOLO mode, even if enabled by a flag.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "blockGitExtensions": { + "title": "Blocks extensions from Git", + "description": "Blocks installing and loading extensions from Git.", + "markdownDescription": "Blocks installing and loading extensions from Git.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "folderTrust": { + "title": "Folder Trust", + "description": "Settings for folder trust.", + "markdownDescription": "Settings for folder trust.\n\n- Category: `Security`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "enabled": { + "title": "Folder Trust", + "description": "Setting to track whether Folder trust is enabled.", + "markdownDescription": "Setting to track whether Folder trust is enabled.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "auth": { + "title": "Authentication", + "description": "Authentication settings.", + "markdownDescription": "Authentication settings.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "selectedType": { + "title": "Selected Auth Type", + "description": "The currently selected authentication type.", + "markdownDescription": "The currently selected authentication type.\n\n- Category: `Security`\n- Requires restart: `yes`", + "type": "string" + }, + "enforcedType": { + "title": "Enforced Auth Type", + "description": "The required auth type. If this does not match the selected auth type, the user will be prompted to re-authenticate.", + "markdownDescription": "The required auth type. If this does not match the selected auth type, the user will be prompted to re-authenticate.\n\n- Category: `Advanced`\n- Requires restart: `yes`", + "type": "string" + }, + "useExternal": { + "title": "Use External Auth", + "description": "Whether to use an external authentication flow.", + "markdownDescription": "Whether to use an external authentication flow.\n\n- Category: `Security`\n- Requires restart: `yes`", + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "advanced": { + "title": "Advanced", + "description": "Advanced settings for power users.", + "markdownDescription": "Advanced settings for power users.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "autoConfigureMemory": { + "title": "Auto Configure Max Old Space Size", + "description": "Automatically configure Node.js memory limits", + "markdownDescription": "Automatically configure Node.js memory limits\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "dnsResolutionOrder": { + "title": "DNS Resolution Order", + "description": "The DNS resolution order.", + "markdownDescription": "The DNS resolution order.\n\n- Category: `Advanced`\n- Requires restart: `yes`", + "type": "string" + }, + "excludedEnvVars": { + "title": "Excluded Project Environment Variables", + "description": "Environment variables to exclude from project context.", + "markdownDescription": "Environment variables to exclude from project context.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `[\n \"DEBUG\",\n \"DEBUG_MODE\"\n]`", + "default": ["DEBUG", "DEBUG_MODE"], + "type": "array", + "items": { + "type": "string" + } + }, + "bugCommand": { + "title": "Bug Command", + "description": "Configuration for the bug report command.", + "markdownDescription": "Configuration for the bug report command.\n\n- Category: `Advanced`\n- Requires restart: `no`", + "$ref": "#/$defs/BugCommandSettings" + } + }, + "additionalProperties": false + }, + "experimental": { + "title": "Experimental", + "description": "Setting to enable experimental features", + "markdownDescription": "Setting to enable experimental features\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "extensionManagement": { + "title": "Extension Management", + "description": "Enable extension management features.", + "markdownDescription": "Enable extension management features.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "extensionReloading": { + "title": "Extension Reloading", + "description": "Enables extension loading/unloading within the CLI session.", + "markdownDescription": "Enables extension loading/unloading within the CLI session.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "isModelAvailabilityServiceEnabled": { + "title": "Enable Model Availability Service", + "description": "Enable model routing using new availability service.", + "markdownDescription": "Enable model routing using new availability service.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "codebaseInvestigatorSettings": { + "title": "Codebase Investigator Settings", + "description": "Configuration for Codebase Investigator.", + "markdownDescription": "Configuration for Codebase Investigator.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "enabled": { + "title": "Enable Codebase Investigator", + "description": "Enable the Codebase Investigator agent.", + "markdownDescription": "Enable the Codebase Investigator agent.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`", + "default": true, + "type": "boolean" + }, + "maxNumTurns": { + "title": "Codebase Investigator Max Num Turns", + "description": "Maximum number of turns for the Codebase Investigator agent.", + "markdownDescription": "Maximum number of turns for the Codebase Investigator agent.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `10`", + "default": 10, + "type": "number" + }, + "maxTimeMinutes": { + "title": "Max Time (Minutes)", + "description": "Maximum time for the Codebase Investigator agent (in minutes).", + "markdownDescription": "Maximum time for the Codebase Investigator agent (in minutes).\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `3`", + "default": 3, + "type": "number" + }, + "thinkingBudget": { + "title": "Thinking Budget", + "description": "The thinking budget for the Codebase Investigator agent.", + "markdownDescription": "The thinking budget for the Codebase Investigator agent.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `8192`", + "default": 8192, + "type": "number" + }, + "model": { + "title": "Model", + "description": "The model to use for the Codebase Investigator agent.", + "markdownDescription": "The model to use for the Codebase Investigator agent.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `gemini-2.5-pro`", + "default": "gemini-2.5-pro", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "extensions": { + "title": "Extensions", + "description": "Settings for extensions.", + "markdownDescription": "Settings for extensions.\n\n- Category: `Extensions`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "disabled": { + "title": "Disabled Extensions", + "description": "List of disabled extensions.", + "markdownDescription": "List of disabled extensions.\n\n- Category: `Extensions`\n- Requires restart: `yes`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "workspacesWithMigrationNudge": { + "title": "Workspaces with Migration Nudge", + "description": "List of workspaces for which the migration nudge has been shown.", + "markdownDescription": "List of workspaces for which the migration nudge has been shown.\n\n- Category: `Extensions`\n- Requires restart: `no`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "hooks": { + "title": "Hooks", + "description": "Hook configurations for intercepting and customizing agent behavior.", + "markdownDescription": "Hook configurations for intercepting and customizing agent behavior.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "additionalProperties": true + } + }, + "$defs": { + "MCPServerConfig": { + "type": "object", + "description": "Definition of a Model Context Protocol (MCP) server configuration.", + "additionalProperties": false, + "properties": { + "command": { + "type": "string", + "description": "Executable invoked for stdio transport." + }, + "args": { + "type": "array", + "description": "Command-line arguments for the stdio transport command.", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "description": "Environment variables to set for the server process.", + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "type": "string", + "description": "Working directory for the server process." + }, + "url": { + "type": "string", + "description": "SSE transport URL." + }, + "httpUrl": { + "type": "string", + "description": "Streaming HTTP transport URL." + }, + "headers": { + "type": "object", + "description": "Additional HTTP headers sent to the server.", + "additionalProperties": { + "type": "string" + } + }, + "tcp": { + "type": "string", + "description": "TCP address for websocket transport." + }, + "timeout": { + "type": "number", + "description": "Timeout in milliseconds for MCP requests." + }, + "trust": { + "type": "boolean", + "description": "Marks the server as trusted. Trusted servers may gain additional capabilities." + }, + "description": { + "type": "string", + "description": "Human-readable description of the server." + }, + "includeTools": { + "type": "array", + "description": "Subset of tools that should be enabled for this server. When omitted all tools are enabled.", + "items": { + "type": "string" + } + }, + "excludeTools": { + "type": "array", + "description": "Tools that should be disabled for this server even if exposed.", + "items": { + "type": "string" + } + }, + "extension": { + "type": "object", + "description": "Metadata describing the Gemini CLI extension that owns this MCP server.", + "additionalProperties": { + "type": ["string", "boolean", "number"] + } + }, + "oauth": { + "type": "object", + "description": "OAuth configuration for authenticating with the server.", + "additionalProperties": true + }, + "authProviderType": { + "type": "string", + "description": "Authentication provider used for acquiring credentials (for example `dynamic_discovery`).", + "enum": ["dynamic_discovery", "google_credentials", "service_account_impersonation"] + }, + "targetAudience": { + "type": "string", + "description": "OAuth target audience (CLIENT_ID.apps.googleusercontent.com)." + }, + "targetServiceAccount": { + "type": "string", + "description": "Service account email to impersonate (name@project.iam.gserviceaccount.com)." + }, + "useInstructions": { + "type": "boolean", + "description": "If true, instructions from this server will be included in the system prompt." + } + } + }, + "TelemetrySettings": { + "type": "object", + "description": "Telemetry configuration for Gemini CLI.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enables telemetry emission." + }, + "target": { + "type": "string", + "description": "Telemetry destination (for example `stderr`, `stdout`, or `otlp`)." + }, + "otlpEndpoint": { + "type": "string", + "description": "Endpoint for OTLP exporters." + }, + "otlpProtocol": { + "type": "string", + "description": "Protocol for OTLP exporters.", + "enum": ["grpc", "http"] + }, + "logPrompts": { + "type": "boolean", + "description": "Whether prompts are logged in telemetry payloads." + }, + "outfile": { + "type": "string", + "description": "File path for writing telemetry output." + }, + "useCollector": { + "type": "boolean", + "description": "Whether to forward telemetry to an OTLP collector." + } + } + }, + "BugCommandSettings": { + "type": "object", + "description": "Configuration for the bug report helper command.", + "additionalProperties": false, + "properties": { + "urlTemplate": { + "type": "string", + "description": "Template used to open a bug report URL. Variables in the template are populated at runtime." + } + }, + "required": ["urlTemplate"] + }, + "SummarizeToolOutputSettings": { + "type": "object", + "description": "Controls summarization behavior for individual tools. All properties are optional.", + "additionalProperties": false, + "properties": { + "tokenBudget": { + "type": "number", + "description": "Maximum number of tokens used when summarizing tool output." + } + } + }, + "CustomTheme": { + "type": "object", + "description": "Custom theme definition used for styling Gemini CLI output. Colors are provided as hex strings or named ANSI colors.", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["custom"], + "default": "custom" + }, + "name": { + "type": "string", + "description": "Theme display name." + }, + "text": { + "type": "object", + "additionalProperties": false, + "properties": { + "primary": { + "type": "string" + }, + "secondary": { + "type": "string" + }, + "link": { + "type": "string" + }, + "accent": { + "type": "string" + } + } + }, + "background": { + "type": "object", + "additionalProperties": false, + "properties": { + "primary": { + "type": "string" + }, + "diff": { + "type": "object", + "additionalProperties": false, + "properties": { + "added": { + "type": "string" + }, + "removed": { + "type": "string" + } + } + } + } + }, + "border": { + "type": "object", + "additionalProperties": false, + "properties": { + "default": { + "type": "string" + }, + "focused": { + "type": "string" + } + } + }, + "ui": { + "type": "object", + "additionalProperties": false, + "properties": { + "comment": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "gradient": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "status": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + }, + "success": { + "type": "string" + }, + "warning": { + "type": "string" + } + } + }, + "Background": { + "type": "string" + }, + "Foreground": { + "type": "string" + }, + "LightBlue": { + "type": "string" + }, + "AccentBlue": { + "type": "string" + }, + "AccentPurple": { + "type": "string" + }, + "AccentCyan": { + "type": "string" + }, + "AccentGreen": { + "type": "string" + }, + "AccentYellow": { + "type": "string" + }, + "AccentRed": { + "type": "string" + }, + "DiffAdded": { + "type": "string" + }, + "DiffRemoved": { + "type": "string" + }, + "Comment": { + "type": "string" + }, + "Gray": { + "type": "string" + }, + "DarkGray": { + "type": "string" + }, + "GradientColors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "name"] + }, + "StringOrStringArray": { + "description": "Accepts either a single string or an array of strings.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "BooleanOrString": { + "description": "Accepts either a boolean flag or a string command name.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + } + } +} diff --git a/src-tauri/resources/original-schema/source-links.json b/src-tauri/resources/original-schema/source-links.json new file mode 100644 index 0000000..3b92d1c --- /dev/null +++ b/src-tauri/resources/original-schema/source-links.json @@ -0,0 +1,4 @@ +{ + "claude-code-schema": "https://www.schemastore.org/claude-code-settings.json", + "gemini-cli-schema": "https://raw.githubusercontent.com/google-gemini/gemini-cli/refs/heads/main/schemas/settings.schema.json" +} From 94d9a90d444fb0724a18a61050e5cd723a9a6bc6 Mon Sep 17 00:00:00 2001 From: Wangnov Date: Tue, 2 Dec 2025 13:45:43 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat(profile):=20=E5=BC=95=E5=85=A5=20Pro?= =?UTF-8?q?file=20=E5=AD=98=E5=82=A8=E4=B8=8E=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 221 ++- src-tauri/Cargo.toml | 9 +- src-tauri/src/commands/config_commands.rs | 88 +- src-tauri/src/models/config.rs | 16 +- src-tauri/src/services/config.rs | 1647 ++++++++++++++++----- src-tauri/src/services/migration.rs | 562 +++++++ src-tauri/src/services/mod.rs | 5 + src-tauri/src/services/profile_store.rs | 548 +++++++ src-tauri/src/utils/config.rs | 41 + 9 files changed, 2742 insertions(+), 395 deletions(-) create mode 100644 src-tauri/src/services/migration.rs create mode 100644 src-tauri/src/services/profile_store.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1daa4bb..82c2e2d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -848,6 +848,7 @@ dependencies = [ "hyper", "hyper-util", "lazy_static", + "notify", "objc", "once_cell", "pin-project-lite", @@ -857,6 +858,8 @@ dependencies = [ "semver", "serde", "serde_json", + "serial_test", + "sha2", "tauri", "tauri-build", "tauri-plugin-shell", @@ -1027,6 +1030,18 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.4" @@ -1100,6 +1115,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -1110,6 +1134,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1117,6 +1156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1184,6 +1224,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1881,6 +1922,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2010,6 +2071,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2076,6 +2157,7 @@ checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall", ] [[package]] @@ -2202,6 +2284,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.0" @@ -2306,6 +2400,25 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.10.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3407,6 +3520,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.28" @@ -3473,6 +3595,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "security-framework" version = "2.11.1" @@ -3662,6 +3790,31 @@ dependencies = [ "syn 2.0.109", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4431,7 +4584,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -5338,6 +5491,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5389,6 +5551,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5446,6 +5623,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5464,6 +5647,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5482,6 +5671,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5512,6 +5707,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5530,6 +5731,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5548,6 +5755,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5566,6 +5779,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d0b9017..2d5db15 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -6,6 +6,10 @@ authors = ["DuckCoding"] edition = "2021" default-run = "duckcoding" +[package.metadata.cargo-llvm-cov] +# 默认行覆盖率阈值,配合 npm run coverage:rs 使用 +fail-under-lines = 90 + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -25,9 +29,10 @@ urlencoding = "2.1" regex = "1" anyhow = "1" thiserror = "1" -chrono = "0.4" +chrono = { version = "0.4", features = ["serde"] } once_cell = "1" semver = "1" +sha2 = "0.10" # 日志系统 tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "fmt"] } @@ -44,9 +49,11 @@ async-trait = "0.1" rusqlite = { version = "0.32", features = ["bundled"] } # 单例模式 lazy_static = "1.5" +notify = "6" [dev-dependencies] tempfile = "3.8" +serial_test = "3" [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.26" diff --git a/src-tauri/src/commands/config_commands.rs b/src-tauri/src/commands/config_commands.rs index 3335e3d..78d8704 100644 --- a/src-tauri/src/commands/config_commands.rs +++ b/src-tauri/src/commands/config_commands.rs @@ -6,9 +6,16 @@ use std::fs; use super::proxy_commands::{ProxyManagerState, TransparentProxyState}; use super::types::ActiveConfig; use ::duckcoding::services::config::{ - CodexSettingsPayload, GeminiEnvPayload, GeminiSettingsPayload, + ClaudeSettingsPayload, CodexSettingsPayload, ExternalConfigChange, GeminiEnvPayload, + GeminiSettingsPayload, ImportExternalChangeResult, +}; +use ::duckcoding::services::migration::LegacyCleanupResult; +use ::duckcoding::services::profile_store::{ + list_descriptors as list_profile_descriptors_internal, read_active_state, read_migration_log, + MigrationRecord, ProfileDescriptor, }; use ::duckcoding::services::proxy::{ProxyConfig, TransparentProxyConfigService}; +use ::duckcoding::services::MigrationService; use ::duckcoding::utils::config::{ apply_proxy_if_configured, read_global_config, write_global_config, }; @@ -535,6 +542,55 @@ pub async fn delete_profile(tool: String, profile: String) -> Result<(), String> Ok(()) } +/// 获取迁移报告 +#[tauri::command] +pub async fn get_migration_report() -> Result, String> { + read_migration_log().map_err(|e| e.to_string()) +} + +/// 获取 profile 元数据列表(可选按工具过滤) +#[tauri::command] +pub async fn list_profile_descriptors( + tool: Option, +) -> Result, String> { + list_profile_descriptors_internal(tool.as_deref()).map_err(|e| e.to_string()) +} + +/// 检测外部配置变更 +#[tauri::command] +pub async fn get_external_changes() -> Result, String> { + ::duckcoding::services::config::ConfigService::detect_external_changes() + .map_err(|e| e.to_string()) +} + +/// 确认外部变更(清除脏标记并刷新 checksum) +#[tauri::command] +pub async fn ack_external_change(tool: String) -> Result<(), String> { + let tool_obj = Tool::by_id(&tool).ok_or_else(|| format!("❌ 未知的工具: {tool}"))?; + ::duckcoding::services::config::ConfigService::acknowledge_external_change(&tool_obj) + .map_err(|e| e.to_string()) +} + +/// 清理旧版备份文件(一次性) +#[tauri::command] +pub async fn clean_legacy_backups() -> Result, String> { + MigrationService::cleanup_legacy_backups().map_err(|e| e.to_string()) +} + +/// 将外部修改导入集中仓 +#[tauri::command] +pub async fn import_native_change( + tool: String, + profile: String, + as_new: bool, +) -> Result { + let tool_obj = Tool::by_id(&tool).ok_or_else(|| format!("❌ 未知的工具: {tool}"))?; + ::duckcoding::services::config::ConfigService::import_external_change( + &tool_obj, &profile, as_new, + ) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn get_active_config(tool: String) -> Result { let home_dir = dirs::home_dir().ok_or("❌ 无法获取用户主目录")?; @@ -573,8 +629,10 @@ pub async fn get_active_config(tool: String) -> Result { .and_then(|v| v.as_str()) .unwrap_or("未配置"); - // 检测配置名称 - let profile_name = if !raw_api_key.is_empty() && base_url != "未配置" { + // 检测配置名称:优先集中仓元数据,其次回退旧目录扫描 + let profile_name = if let Ok(Some(state)) = read_active_state("claude-code") { + state.profile_name + } else if !raw_api_key.is_empty() && base_url != "未配置" { detect_profile_name("claude-code", raw_api_key, base_url, &home_dir) } else { None @@ -648,7 +706,9 @@ pub async fn get_active_config(tool: String) -> Result { } // 检测配置名称 - let profile_name = if !raw_api_key.is_empty() && base_url != "未配置" { + let profile_name = if let Ok(Some(state)) = read_active_state("codex") { + state.profile_name + } else if !raw_api_key.is_empty() && base_url != "未配置" { detect_profile_name("codex", &raw_api_key, &base_url, &home_dir) } else { None @@ -696,7 +756,9 @@ pub async fn get_active_config(tool: String) -> Result { } // 检测配置名称 - let profile_name = if !raw_api_key.is_empty() && base_url != "未配置" { + let profile_name = if let Ok(Some(state)) = read_active_state("gemini-cli") { + state.profile_name + } else if !raw_api_key.is_empty() && base_url != "未配置" { detect_profile_name("gemini-cli", &raw_api_key, &base_url, &home_dir) } else { None @@ -843,13 +905,21 @@ pub async fn generate_api_key_for_tool(tool: String) -> Result Result { - ConfigService::read_claude_settings().map_err(|e| e.to_string()) +pub fn get_claude_settings() -> Result { + ConfigService::read_claude_settings() + .map(|settings| { + let extra = ConfigService::read_claude_extra_config().ok(); + ClaudeSettingsPayload { + settings, + extra_config: extra, + } + }) + .map_err(|e| e.to_string()) } #[tauri::command] -pub fn save_claude_settings(settings: Value) -> Result<(), String> { - ConfigService::save_claude_settings(&settings).map_err(|e| e.to_string()) +pub fn save_claude_settings(settings: Value, extra_config: Option) -> Result<(), String> { + ConfigService::save_claude_settings(&settings, extra_config.as_ref()).map_err(|e| e.to_string()) } #[tauri::command] diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index 4496cb3..79e66a6 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -70,7 +70,7 @@ pub struct LogConfig { } /// 新用户引导状态 -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct OnboardingStatus { /// 已完成的引导版本(例如:"v1", "v2") pub completed_version: String, @@ -177,6 +177,12 @@ pub struct GlobalConfig { // 新用户引导状态 #[serde(default)] pub onboarding_status: Option, + /// 外部改动监听是否开启(notify + 轮询) + #[serde(default = "default_external_watch_enabled")] + pub external_watch_enabled: bool, + /// 外部改动轮询间隔(毫秒),用于前端补偿刷新 + #[serde(default = "default_external_poll_interval_ms")] + pub external_poll_interval_ms: u64, } fn default_transparent_proxy_port() -> u16 { @@ -282,3 +288,11 @@ impl GlobalConfig { } } } + +fn default_external_watch_enabled() -> bool { + true +} + +fn default_external_poll_interval_ms() -> u64 { + 5000 +} diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 10daf76..8490f34 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -1,5 +1,12 @@ use crate::models::Tool; +use crate::services::migration::MigrationService; +use crate::services::profile_store::{ + delete_profile as delete_stored_profile, list_profile_names as list_stored_profiles, + load_profile_payload, read_active_state, save_active_state, save_profile_payload, + ActiveProfileState, ProfilePayload, +}; use anyhow::{anyhow, Context, Result}; +use chrono::Utc; use once_cell::sync::OnceCell; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -9,17 +16,6 @@ use std::path::Path; use toml; use toml_edit::{DocumentMut, Item, Table}; -// Codex provider 配置必需字段 -const CODEX_PROVIDER_REQUIRED_FIELDS: &[&str] = - &["name", "base_url", "wire_api", "requires_openai_auth"]; - -/// 检查 Codex provider 配置是否完整(包含所有必需字段) -fn is_complete_provider_config(table: &toml_edit::Table) -> bool { - CODEX_PROVIDER_REQUIRED_FIELDS - .iter() - .all(|field| table.contains_key(field)) -} - #[derive(Serialize, Deserialize)] pub struct CodexSettingsPayload { pub config: Value, @@ -27,6 +23,14 @@ pub struct CodexSettingsPayload { pub auth_token: Option, } +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeSettingsPayload { + pub settings: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_config: Option, +} + fn merge_toml_tables(target: &mut Table, source: &Table) { let keys_to_remove: Vec = target .iter() @@ -91,6 +95,548 @@ fn merge_toml_tables(target: &mut Table, source: &Table) { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{EnvVars, Tool}; + use crate::services::profile_store::{file_checksum, load_profile_payload}; + use serial_test::serial; + use std::env; + use std::fs; + use tempfile::TempDir; + + struct TempEnvGuard { + config_dir: Option, + home: Option, + userprofile: Option, + } + + impl TempEnvGuard { + fn new(dir: &TempDir) -> Self { + let config_dir = env::var("DUCKCODING_CONFIG_DIR").ok(); + let home = env::var("HOME").ok(); + let userprofile = env::var("USERPROFILE").ok(); + env::set_var("DUCKCODING_CONFIG_DIR", dir.path()); + env::set_var("HOME", dir.path()); + env::set_var("USERPROFILE", dir.path()); + Self { + config_dir, + home, + userprofile, + } + } + } + + impl Drop for TempEnvGuard { + fn drop(&mut self) { + match &self.config_dir { + Some(val) => env::set_var("DUCKCODING_CONFIG_DIR", val), + None => env::remove_var("DUCKCODING_CONFIG_DIR"), + }; + match &self.home { + Some(val) => env::set_var("HOME", val), + None => env::remove_var("HOME"), + }; + match &self.userprofile { + Some(val) => env::set_var("USERPROFILE", val), + None => env::remove_var("USERPROFILE"), + }; + } + } + + fn make_temp_tool(id: &str, config_file: &str, base: &TempDir) -> Tool { + Tool { + id: id.to_string(), + name: format!("{id}-tool"), + group_name: "test".to_string(), + npm_package: "pkg".to_string(), + check_command: "cmd".to_string(), + config_dir: base.path().join(id), + config_file: config_file.to_string(), + env_vars: EnvVars { + api_key: "API_KEY".to_string(), + base_url: "BASE_URL".to_string(), + }, + use_proxy_for_version_check: false, + } + } + + #[test] + #[serial] + fn mark_external_change_clears_dirty_when_checksum_unchanged() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = make_temp_tool("test-tool", "settings.json", &temp); + fs::create_dir_all(&tool.config_dir)?; + + let first = ConfigService::mark_external_change( + &tool, + tool.config_dir.join(&tool.config_file), + Some("abc".to_string()), + )?; + assert!(first.dirty); + + let second = ConfigService::mark_external_change( + &tool, + tool.config_dir.join(&tool.config_file), + Some("abc".to_string()), + )?; + assert!( + !second.dirty, + "same checksum should not keep dirty flag true" + ); + + let state = read_active_state(&tool.id)?.expect("state should exist"); + assert_eq!(state.native_checksum, Some("abc".to_string())); + assert!(!state.dirty); + Ok(()) + } + + #[test] + #[serial] + fn mark_external_change_preserves_last_synced_at() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = make_temp_tool("test-tool-sync", "settings.json", &temp); + fs::create_dir_all(&tool.config_dir)?; + + let original_time = Utc::now(); + let initial_state = ActiveProfileState { + profile_name: Some("profile-a".to_string()), + native_checksum: Some("old-checksum".to_string()), + last_synced_at: Some(original_time), + dirty: false, + }; + save_active_state(&tool.id, &initial_state)?; + + let change = ConfigService::mark_external_change( + &tool, + tool.config_dir.join(&tool.config_file), + Some("new-checksum".to_string()), + )?; + assert!(change.dirty, "checksum change should mark dirty"); + + let state = read_active_state(&tool.id)?.expect("state should exist"); + assert_eq!( + state.last_synced_at, + Some(original_time), + "detection should not move last_synced_at" + ); + Ok(()) + } + + #[test] + #[serial] + fn import_external_change_for_codex_writes_profile_and_state() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = make_temp_tool("codex", "config.toml", &temp); + fs::create_dir_all(&tool.config_dir)?; + + let config_path = tool.config_dir.join(&tool.config_file); + fs::write( + &config_path, + r#" +model_provider = "duckcoding" +[model_providers.duckcoding] +base_url = "https://example.com/v1" +"#, + )?; + let auth_path = tool.config_dir.join("auth.json"); + fs::write(&auth_path, r#"{"OPENAI_API_KEY":"test-key"}"#)?; + + let result = ConfigService::import_external_change(&tool, "profile-a", false)?; + assert_eq!(result.profile_name, "profile-a"); + assert!(!result.was_new); + + let payload = load_profile_payload("codex", "profile-a")?; + match payload { + ProfilePayload::Codex { + api_key, + base_url, + provider, + raw_config_toml, + raw_auth_json, + } => { + assert_eq!(api_key, "test-key"); + assert_eq!(base_url, "https://example.com/v1"); + assert_eq!(provider, Some("duckcoding".to_string())); + assert!(raw_config_toml.is_some()); + assert!(raw_auth_json.is_some()); + } + other => panic!("unexpected payload variant: {:?}", other), + } + + let state = read_active_state("codex")?.expect("active state should exist"); + assert_eq!(state.profile_name, Some("profile-a".to_string())); + assert!(!state.dirty); + Ok(()) + } + + #[test] + #[serial] + fn apply_config_persists_claude_profile_and_state() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = Tool::claude_code(); + + ConfigService::apply_config(&tool, "k-1", "https://api.claude.com", Some("dev"))?; + + let settings_path = tool.config_dir.join(&tool.config_file); + let content = fs::read_to_string(&settings_path)?; + let json: Value = serde_json::from_str(&content)?; + let env_obj = json + .get("env") + .and_then(|v| v.as_object()) + .expect("env exists"); + assert_eq!( + env_obj.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()), + Some("k-1") + ); + assert_eq!( + env_obj.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()), + Some("https://api.claude.com") + ); + + let payload = load_profile_payload("claude-code", "dev")?; + match payload { + ProfilePayload::Claude { + api_key, + base_url, + raw_settings, + raw_config_json, + } => { + assert_eq!(api_key, "k-1"); + assert_eq!(base_url, "https://api.claude.com"); + assert!(raw_settings.is_some()); + assert!(raw_config_json.is_none()); + } + _ => panic!("unexpected payload"), + } + + let state = read_active_state("claude-code")?.expect("state exists"); + assert_eq!(state.profile_name, Some("dev".to_string())); + assert!(!state.dirty); + Ok(()) + } + + #[test] + #[serial] + fn detect_and_ack_external_change_updates_state() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = make_temp_tool("claude-code", "settings.json", &temp); + fs::create_dir_all(&tool.config_dir)?; + let path = tool.config_dir.join(&tool.config_file); + fs::write( + &path, + r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"a","ANTHROPIC_BASE_URL":"https://a"}}"#, + )?; + let initial_checksum = file_checksum(&path).ok(); + save_active_state( + &tool.id, + &ActiveProfileState { + profile_name: Some("default".to_string()), + native_checksum: initial_checksum.clone(), + last_synced_at: None, + dirty: false, + }, + )?; + + // modify file + fs::write( + &path, + r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"b","ANTHROPIC_BASE_URL":"https://b"}}"#, + )?; + let changes = ConfigService::detect_external_changes()?; + assert_eq!(changes.len(), 1); + assert!(changes[0].dirty); + + let state_dirty = read_active_state(&tool.id)?.expect("state exists"); + assert!(state_dirty.dirty); + + ConfigService::acknowledge_external_change(&tool)?; + let state_clean = read_active_state(&tool.id)?.expect("state exists"); + assert!(!state_clean.dirty); + assert_ne!(state_clean.native_checksum, initial_checksum); + Ok(()) + } + + #[test] + #[serial] + fn detect_external_changes_tracks_codex_auth_file() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = Tool::codex(); + + fs::create_dir_all(&tool.config_dir)?; + let config_path = tool.config_dir.join(&tool.config_file); + let auth_path = tool.config_dir.join("auth.json"); + fs::write( + &config_path, + r#"model_provider = "duckcoding" +[model_providers.duckcoding] +base_url = "https://example.com/v1" +"#, + )?; + fs::write(&auth_path, r#"{"OPENAI_API_KEY":"old"}"#)?; + + let checksum = ConfigService::compute_native_checksum(&tool); + save_active_state( + &tool.id, + &ActiveProfileState { + profile_name: Some("default".to_string()), + native_checksum: checksum, + last_synced_at: None, + dirty: false, + }, + )?; + + // 仅修改 auth.json,应当被检测到 + fs::write(&auth_path, r#"{"OPENAI_API_KEY":"new"}"#)?; + let changes = ConfigService::detect_external_changes()?; + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].tool_id, "codex"); + assert!(changes[0].dirty); + Ok(()) + } + + #[test] + #[serial] + fn detect_external_changes_tracks_gemini_env_file() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = Tool::gemini_cli(); + + fs::create_dir_all(&tool.config_dir)?; + let settings_path = tool.config_dir.join(&tool.config_file); + let env_path = tool.config_dir.join(".env"); + fs::write(&settings_path, r#"{"ide":{"enabled":true}}"#)?; + fs::write( + &env_path, + "GEMINI_API_KEY=old\nGOOGLE_GEMINI_BASE_URL=https://g.com\nGEMINI_MODEL=gemini-2.5-pro\n", + )?; + + let checksum = ConfigService::compute_native_checksum(&tool); + save_active_state( + &tool.id, + &ActiveProfileState { + profile_name: Some("default".to_string()), + native_checksum: checksum, + last_synced_at: None, + dirty: false, + }, + )?; + + fs::write( + &env_path, + "GEMINI_API_KEY=new\nGOOGLE_GEMINI_BASE_URL=https://g.com\nGEMINI_MODEL=gemini-2.5-pro\n", + )?; + + let changes = ConfigService::detect_external_changes()?; + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].tool_id, "gemini-cli"); + assert!(changes[0].dirty); + Ok(()) + } + + #[test] + #[serial] + fn detect_external_changes_tracks_claude_extra_config() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = Tool::claude_code(); + + fs::create_dir_all(&tool.config_dir)?; + let settings_path = tool.config_dir.join(&tool.config_file); + let extra_path = tool.config_dir.join("config.json"); + fs::write( + &settings_path, + r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"a","ANTHROPIC_BASE_URL":"https://a"}}"#, + )?; + fs::write(&extra_path, r#"{"project":"duckcoding"}"#)?; + + let checksum = ConfigService::compute_native_checksum(&tool); + save_active_state( + &tool.id, + &ActiveProfileState { + profile_name: Some("default".to_string()), + native_checksum: checksum, + last_synced_at: None, + dirty: false, + }, + )?; + + fs::write(&extra_path, r#"{"project":"duckcoding-updated"}"#)?; + let changes = ConfigService::detect_external_changes()?; + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].tool_id, "claude-code"); + assert!(changes[0].dirty); + Ok(()) + } + + #[test] + #[serial] + fn apply_config_codex_sets_provider_and_auth() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = Tool::codex(); + + ConfigService::apply_config( + &tool, + "codex-key", + "https://duckcoding.example/v1", + Some("main"), + )?; + + let config_path = tool.config_dir.join(&tool.config_file); + let toml_content = fs::read_to_string(&config_path)?; + assert!( + toml_content.contains("model_provider = \"duckcoding\"") + || toml_content.contains("model_provider=\"duckcoding\"") + ); + assert!(toml_content.contains("https://duckcoding.example/v1")); + + let auth_path = tool.config_dir.join("auth.json"); + let auth_content = fs::read_to_string(&auth_path)?; + let auth_json: Value = serde_json::from_str(&auth_content)?; + assert_eq!( + auth_json.get("OPENAI_API_KEY").and_then(|v| v.as_str()), + Some("codex-key") + ); + + let payload = load_profile_payload("codex", "main")?; + match payload { + ProfilePayload::Codex { + api_key, + base_url, + provider, + raw_config_toml, + raw_auth_json, + } => { + assert_eq!(api_key, "codex-key"); + assert_eq!(base_url, "https://duckcoding.example/v1"); + assert_eq!(provider, Some("duckcoding".to_string())); + assert!(raw_config_toml.is_some()); + assert!(raw_auth_json.is_some()); + } + _ => panic!("unexpected payload"), + } + + Ok(()) + } + + #[test] + #[serial] + fn save_claude_settings_writes_extra_config() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = Tool::claude_code(); + + let settings = serde_json::json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "k-claude", + "ANTHROPIC_BASE_URL": "https://claude.example" + } + }); + let extra = serde_json::json!({"project": "duckcoding"}); + + ConfigService::save_claude_settings(&settings, Some(&extra))?; + + let extra_path = tool.config_dir.join("config.json"); + let saved_extra: Value = serde_json::from_str(&fs::read_to_string(&extra_path)?)?; + assert_eq!( + saved_extra.get("project").and_then(|v| v.as_str()), + Some("duckcoding") + ); + + let payload = load_profile_payload("claude-code", "default")?; + match payload { + ProfilePayload::Claude { + api_key, + base_url, + raw_settings, + raw_config_json, + } => { + assert_eq!(api_key, "k-claude"); + assert_eq!(base_url, "https://claude.example"); + assert!(raw_settings.is_some()); + assert!(raw_config_json.is_some()); + } + _ => panic!("unexpected payload"), + } + Ok(()) + } + + #[test] + #[serial] + fn apply_config_gemini_sets_model_and_env() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = Tool::gemini_cli(); + + ConfigService::apply_config(&tool, "gem-key", "https://gem.com", Some("blue"))?; + + let env_path = tool.config_dir.join(".env"); + let env_content = fs::read_to_string(&env_path)?; + assert!(env_content.contains("GEMINI_API_KEY=gem-key")); + assert!(env_content.contains("GOOGLE_GEMINI_BASE_URL=https://gem.com")); + assert!(env_content.contains("GEMINI_MODEL=gemini-2.5-pro")); + + let payload = load_profile_payload("gemini-cli", "blue")?; + match payload { + ProfilePayload::Gemini { + api_key, + base_url, + model, + raw_settings, + raw_env, + } => { + assert_eq!(api_key, "gem-key"); + assert_eq!(base_url, "https://gem.com"); + assert_eq!(model, "gemini-2.5-pro"); + assert!(raw_settings.is_some()); + assert!(raw_env.is_some()); + } + _ => panic!("unexpected payload"), + } + Ok(()) + } + + #[test] + #[serial] + fn delete_profile_marks_active_dirty_when_matching() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + let _guard = TempEnvGuard::new(&temp); + let tool = Tool::claude_code(); + save_profile_payload( + &tool.id, + "temp", + &ProfilePayload::Claude { + api_key: "x".to_string(), + base_url: "https://x".to_string(), + raw_settings: None, + raw_config_json: None, + }, + )?; + save_active_state( + &tool.id, + &ActiveProfileState { + profile_name: Some("temp".to_string()), + native_checksum: Some("old".to_string()), + last_synced_at: None, + dirty: false, + }, + )?; + + ConfigService::delete_profile(&tool, "temp")?; + let state = read_active_state(&tool.id)?.expect("state exists"); + assert!(state.dirty); + assert!(state.profile_name.is_none()); + Ok(()) + } +} + fn set_table_value(table: &mut Table, key: &str, value: Item) { match value { Item::Value(new_value) => { @@ -121,6 +667,25 @@ pub struct GeminiSettingsPayload { pub settings: Value, pub env: GeminiEnvPayload, } + +#[derive(Debug, Clone, Serialize)] +pub struct ExternalConfigChange { + pub tool_id: String, + pub path: String, + pub checksum: Option, + pub detected_at: chrono::DateTime, + pub dirty: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportExternalChangeResult { + pub profile_name: String, + pub was_new: bool, + pub replaced: bool, + pub before_checksum: Option, + pub checksum: Option, +} /// 配置服务 pub struct ConfigService; @@ -132,17 +697,50 @@ impl ConfigService { base_url: &str, profile_name: Option<&str>, ) -> Result<()> { - match tool.id.as_str() { - "claude-code" => Self::apply_claude_config(tool, api_key, base_url)?, - "codex" => Self::apply_codex_config(tool, api_key, base_url)?, - "gemini-cli" => Self::apply_gemini_config(tool, api_key, base_url)?, + MigrationService::run_if_needed(); + let payload = match tool.id.as_str() { + "claude-code" => { + Self::apply_claude_config(tool, api_key, base_url)?; + let (raw_settings, raw_config_json) = Self::read_claude_raw(tool); + ProfilePayload::Claude { + api_key: api_key.to_string(), + base_url: base_url.to_string(), + raw_settings, + raw_config_json, + } + } + "codex" => { + let provider = if base_url.contains("duckcoding") { + Some("duckcoding".to_string()) + } else { + Some("custom".to_string()) + }; + Self::apply_codex_config(tool, api_key, base_url, provider.as_deref())?; + let (raw_config_toml, raw_auth_json) = Self::read_codex_raw(tool); + ProfilePayload::Codex { + api_key: api_key.to_string(), + base_url: base_url.to_string(), + provider, + raw_config_toml, + raw_auth_json, + } + } + "gemini-cli" => { + Self::apply_gemini_config(tool, api_key, base_url, None)?; + let (raw_settings, raw_env) = Self::read_gemini_raw(tool); + ProfilePayload::Gemini { + api_key: api_key.to_string(), + base_url: base_url.to_string(), + model: "gemini-2.5-pro".to_string(), + raw_settings, + raw_env, + } + } _ => anyhow::bail!("未知工具: {}", tool.id), - } + }; - // 保存命名配置的备份副本 - if let Some(profile) = profile_name { - Self::save_backup(tool, profile)?; - } + let profile_to_save = profile_name.unwrap_or("default"); + Self::persist_payload_for_tool(tool, profile_to_save, &payload)?; Ok(()) } @@ -196,11 +794,41 @@ impl ConfigService { fs::set_permissions(&config_path, perms)?; } + let env_obj = settings + .get("env") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + let api_key = env_obj + .get("ANTHROPIC_AUTH_TOKEN") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let base_url = env_obj + .get("ANTHROPIC_BASE_URL") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let profile_name = Self::profile_name_for_sync(&tool.id); + let (raw_settings, raw_config_json) = Self::read_claude_raw(tool); + let payload = ProfilePayload::Claude { + api_key, + base_url, + raw_settings, + raw_config_json, + }; + Self::persist_payload_for_tool(tool, &profile_name, &payload)?; + Ok(()) } /// CodeX 配置(使用 toml_edit 保留注释和格式) - fn apply_codex_config(tool: &Tool, api_key: &str, base_url: &str) -> Result<()> { + fn apply_codex_config( + tool: &Tool, + api_key: &str, + base_url: &str, + provider_override: Option<&str>, + ) -> Result<()> { let config_path = tool.config_dir.join(&tool.config_file); let auth_path = tool.config_dir.join("auth.json"); @@ -220,11 +848,11 @@ impl ConfigService { // 判断 provider 类型 let is_duckcoding = base_url.contains("duckcoding"); - let provider_key = if is_duckcoding { + let provider_key = provider_override.unwrap_or(if is_duckcoding { "duckcoding" } else { "custom" - }; + }); // 只更新必要字段(保留用户自定义配置和注释) if !root_table.contains_key("model") { @@ -322,7 +950,12 @@ impl ConfigService { } /// Gemini CLI 配置 - fn apply_gemini_config(tool: &Tool, api_key: &str, base_url: &str) -> Result<()> { + fn apply_gemini_config( + tool: &Tool, + api_key: &str, + base_url: &str, + model_override: Option<&str>, + ) -> Result<()> { let env_path = tool.config_dir.join(".env"); let settings_path = tool.config_dir.join(&tool.config_file); @@ -346,9 +979,11 @@ impl ConfigService { // 更新 API 相关字段 env_vars.insert("GOOGLE_GEMINI_BASE_URL".to_string(), base_url.to_string()); env_vars.insert("GEMINI_API_KEY".to_string(), api_key.to_string()); - if !env_vars.contains_key("GEMINI_MODEL") { - env_vars.insert("GEMINI_MODEL".to_string(), "gemini-2.5-pro".to_string()); - } + let model_value = model_override + .map(|m| m.to_string()) + .or_else(|| env_vars.get("GEMINI_MODEL").cloned()) + .unwrap_or_else(|| "gemini-2.5-pro".to_string()); + env_vars.insert("GEMINI_MODEL".to_string(), model_value); // 写入 .env let env_content: Vec = env_vars.iter().map(|(k, v)| format!("{k}={v}")).collect(); @@ -549,393 +1184,167 @@ impl ConfigService { /// 列出所有保存的配置 pub fn list_profiles(tool: &Tool) -> Result> { - if !tool.config_dir.exists() { - return Ok(vec![]); - } - - let entries = fs::read_dir(&tool.config_dir)?; - let mut profiles = Vec::new(); - - // 时间戳格式正则: YYYYMMDD-HHMMSS - let timestamp_pattern = regex::Regex::new(r"^\d{8}-\d{6}$").unwrap(); - - for entry in entries { - let entry = entry?; - let filename = entry.file_name(); - let filename_str = filename.to_string_lossy(); - - match tool.id.as_str() { - "claude-code" => { - // 排除主配置文件本身 (settings.json) - if filename_str == tool.config_file { - continue; - } + MigrationService::run_if_needed(); + list_stored_profiles(&tool.id) + } - if filename_str.starts_with("settings.") && filename_str.ends_with(".json") { - let profile = filename_str - .trim_start_matches("settings.") - .trim_end_matches(".json") - .to_string(); - - if !profile.is_empty() - && !profile.starts_with('.') - && !timestamp_pattern.is_match(&profile) - { - profiles.push(profile); + /// 激活指定的配置 + pub fn activate_profile(tool: &Tool, profile_name: &str) -> Result<()> { + MigrationService::run_if_needed(); + let payload = load_profile_payload(&tool.id, profile_name)?; + match (tool.id.as_str(), payload) { + ( + "claude-code", + ProfilePayload::Claude { + api_key, + base_url, + raw_settings, + raw_config_json, + }, + ) => { + fs::create_dir_all(&tool.config_dir)?; + let settings_path = tool.config_dir.join(&tool.config_file); + let extra_config_path = tool.config_dir.join("config.json"); + + match (raw_settings, raw_config_json) { + (Some(settings), extra) => { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + if let Some(cfg) = extra { + fs::write(&extra_config_path, serde_json::to_string_pretty(&cfg)?)?; } } - } - "codex" => { - // 排除主配置文件本身 (config.toml、auth.json) - if filename_str == tool.config_file || filename_str == "auth.json" { - continue; - } - - let profile = if filename_str.starts_with("config.") - && filename_str.ends_with(".toml") - { - Some( - filename_str - .trim_start_matches("config.") - .trim_end_matches(".toml") - .to_string(), - ) - } else if filename_str.starts_with("auth.") && filename_str.ends_with(".json") { - Some( - filename_str - .trim_start_matches("auth.") - .trim_end_matches(".json") - .to_string(), - ) - } else { - None - }; - - if let Some(profile) = profile { - if !profile.is_empty() - && !profile.starts_with('.') - && !timestamp_pattern.is_match(&profile) - { - profiles.push(profile); - } + _ => { + Self::apply_claude_config(tool, &api_key, &base_url)?; } } - "gemini-cli" => { - // 排除主配置文件 (.env) - if filename_str == tool.config_file { - continue; - } - - if filename_str.starts_with(".env.") { - let profile = filename_str.trim_start_matches(".env.").to_string(); - if !profile.is_empty() - && !profile.starts_with('.') - && !timestamp_pattern.is_match(&profile) - { - profiles.push(profile); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [&settings_path, &extra_config_path] { + if path.exists() { + let metadata = fs::metadata(path)?; + let mut perms = metadata.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; } } } - _ => {} } - } - - profiles.sort(); - profiles.dedup(); - Ok(profiles) - } - - /// 激活指定的配置 - pub fn activate_profile(tool: &Tool, profile_name: &str) -> Result<()> { - match tool.id.as_str() { - "claude-code" => Self::activate_claude(tool, profile_name)?, - "codex" => Self::activate_codex(tool, profile_name)?, - "gemini-cli" => Self::activate_gemini(tool, profile_name)?, - _ => anyhow::bail!("未知工具: {}", tool.id), - } - Ok(()) - } - - fn activate_claude(tool: &Tool, profile_name: &str) -> Result<()> { - let backup_path = tool.backup_path(profile_name); - let active_path = tool.config_dir.join(&tool.config_file); - - if !backup_path.exists() { - anyhow::bail!("配置文件不存在: {backup_path:?}"); - } - - // 读取备份的 API 字段(兼容新旧格式) - let backup_content = fs::read_to_string(&backup_path).context("读取备份配置失败")?; - let backup_data: Value = - serde_json::from_str(&backup_content).context("解析备份配置失败")?; - - // 兼容旧格式:先尝试顶层字段(新格式),再尝试 env 下(旧格式) - let api_key = backup_data - .get("ANTHROPIC_AUTH_TOKEN") - .and_then(|v| v.as_str()) - .or_else(|| { - backup_data - .get("env") - .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) - .and_then(|v| v.as_str()) - }) - .ok_or_else(|| { - anyhow::anyhow!("备份配置格式错误:缺少 API Key\n\n请重新保存配置以更新格式") - })?; - - let base_url = backup_data - .get("ANTHROPIC_BASE_URL") - .and_then(|v| v.as_str()) - .or_else(|| { - backup_data - .get("env") - .and_then(|env| env.get("ANTHROPIC_BASE_URL")) - .and_then(|v| v.as_str()) - }) - .ok_or_else(|| { - anyhow::anyhow!("备份配置格式错误:缺少 Base URL\n\n请重新保存配置以更新格式") - })?; - - // 读取当前配置(保留其他字段) - let mut settings = if active_path.exists() { - let content = fs::read_to_string(&active_path).context("读取当前配置失败")?; - serde_json::from_str::(&content).unwrap_or(Value::Object(Map::new())) - } else { - Value::Object(Map::new()) - }; - - // 只更新 env 中的 API 字段,保留其他配置 - if !settings.is_object() { - settings = serde_json::json!({}); - } - - let obj = settings.as_object_mut().unwrap(); - if !obj.contains_key("env") { - obj.insert("env".to_string(), Value::Object(Map::new())); - } - - let env = obj.get_mut("env").unwrap().as_object_mut().unwrap(); - env.insert( - "ANTHROPIC_AUTH_TOKEN".to_string(), - Value::String(api_key.to_string()), - ); - env.insert( - "ANTHROPIC_BASE_URL".to_string(), - Value::String(base_url.to_string()), - ); - - // 写回配置(保留其他字段) - fs::write(&active_path, serde_json::to_string_pretty(&settings)?)?; - - Ok(()) - } - - fn activate_codex(tool: &Tool, profile_name: &str) -> Result<()> { - let backup_config = tool.config_dir.join(format!("config.{profile_name}.toml")); - let backup_auth = tool.config_dir.join(format!("auth.{profile_name}.json")); - - let active_config = tool.config_dir.join("config.toml"); - let active_auth = tool.config_dir.join("auth.json"); - - if !backup_auth.exists() { - anyhow::bail!("配置文件不存在: {backup_auth:?}"); - } - - // 读取备份的 API Key - let backup_auth_content = fs::read_to_string(&backup_auth)?; - let backup_auth_data: Value = serde_json::from_str(&backup_auth_content)?; - let api_key = backup_auth_data - .get("OPENAI_API_KEY") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("备份配置中缺少 API Key"))?; - - // 增量更新 auth.json(保留其他字段) - let mut auth_data = if active_auth.exists() { - let content = fs::read_to_string(&active_auth)?; - serde_json::from_str::(&content).unwrap_or(Value::Object(Map::new())) - } else { - Value::Object(Map::new()) - }; - - if let Value::Object(ref mut auth_obj) = auth_data { - auth_obj.insert( - "OPENAI_API_KEY".to_string(), - Value::String(api_key.to_string()), - ); - } - - fs::write(&active_auth, serde_json::to_string_pretty(&auth_data)?)?; - - // 读取备份的 config.toml(base_url 和 model_provider) - if backup_config.exists() { - let backup_config_content = fs::read_to_string(&backup_config)?; - let backup_doc = backup_config_content.parse::()?; - - // 读取当前 config.toml(保留其他配置) - let mut active_doc = if active_config.exists() { - let content = fs::read_to_string(&active_config)?; - content - .parse::() - .unwrap_or_else(|_| toml_edit::DocumentMut::new()) - } else { - toml_edit::DocumentMut::new() - }; + ( + "codex", + ProfilePayload::Codex { + api_key, + base_url, + provider, + raw_config_toml, + raw_auth_json, + }, + ) => { + fs::create_dir_all(&tool.config_dir)?; + let config_path = tool.config_dir.join(&tool.config_file); + let auth_path = tool.config_dir.join("auth.json"); + + if let Some(raw) = raw_config_toml { + fs::write(&config_path, raw)?; + } else { + Self::apply_codex_config(tool, &api_key, &base_url, provider.as_deref())?; + } - // 只更新 model_providers 中的配置(保留其他字段) - if let Some(backup_providers) = - backup_doc.get("model_providers").and_then(|p| p.as_table()) - { - if !active_doc.contains_key("model_providers") { - active_doc["model_providers"] = toml_edit::table(); + if let Some(auth) = raw_auth_json { + fs::write(&auth_path, serde_json::to_string_pretty(&auth)?)?; } - // 获取 model_providers 表的可变引用 - if let Some(active_providers) = active_doc - .get_mut("model_providers") - .and_then(|p| p.as_table_mut()) + #[cfg(unix)] { - for (key, backup_provider) in backup_providers.iter() { - if let Some(backup_provider_table) = backup_provider.as_table() { - if backup_provider_table.get("base_url").is_some() { - // 如果 provider 不存在,需要创建 - if !active_providers.contains_key(key) { - // 检查备份文件格式:新格式包含完整字段,旧格式只有 base_url - if is_complete_provider_config(backup_provider_table) { - // 新格式:完整配置,直接复制 - active_providers.insert(key, backup_provider.clone()); - } else { - // 旧格式:只有 base_url,需要补全必要字段(向后兼容) - let mut new_provider = toml_edit::Table::new(); - new_provider.insert("name", toml_edit::value(key)); - new_provider.insert( - "base_url", - backup_provider_table.get("base_url").unwrap().clone(), - ); - new_provider - .insert("wire_api", toml_edit::value("responses")); - new_provider - .insert("requires_openai_auth", toml_edit::value(true)); - active_providers - .insert(key, toml_edit::Item::Table(new_provider)); - } - } else { - // 如果已存在,只更新 base_url(保留用户自定义配置) - if let Some(active_provider) = - active_providers.get_mut(key).and_then(|p| p.as_table_mut()) - { - if let Some(base_url) = - backup_provider_table.get("base_url") - { - active_provider.insert("base_url", base_url.clone()); - } - } - } - } + use std::os::unix::fs::PermissionsExt; + for path in [&config_path, &auth_path] { + if path.exists() { + let metadata = fs::metadata(path)?; + let mut perms = metadata.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; } } } } - - // 更新 model_provider 选择(如果备份中有) - if let Some(provider) = backup_doc.get("model_provider") { - active_doc["model_provider"] = provider.clone(); - } - - // 写回 config.toml(保留其他字段和注释) - fs::write(&active_config, active_doc.to_string())?; - } - - Ok(()) - } - - fn activate_gemini(tool: &Tool, profile_name: &str) -> Result<()> { - let backup_env = tool.config_dir.join(format!(".env.{profile_name}")); - let active_env = tool.config_dir.join(".env"); - - if !backup_env.exists() { - anyhow::bail!("配置文件不存在: {backup_env:?}"); - } - - // 读取备份的 API 字段 - let backup_content = fs::read_to_string(&backup_env)?; - let mut backup_api_key = String::new(); - let mut backup_base_url = String::new(); - let mut backup_model = String::new(); - - for line in backup_content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - if let Some((key, value)) = trimmed.split_once('=') { - match key.trim() { - "GEMINI_API_KEY" => backup_api_key = value.trim().to_string(), - "GOOGLE_GEMINI_BASE_URL" => backup_base_url = value.trim().to_string(), - "GEMINI_MODEL" => backup_model = value.trim().to_string(), - _ => {} + ( + "gemini-cli", + ProfilePayload::Gemini { + api_key, + base_url, + model, + raw_settings, + raw_env, + }, + ) => { + fs::create_dir_all(&tool.config_dir)?; + let settings_path = tool.config_dir.join(&tool.config_file); + let env_path = tool.config_dir.join(".env"); + + match (raw_settings, raw_env) { + (Some(settings), Some(env_raw)) => { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + fs::write(&env_path, env_raw)?; + } + (Some(settings), None) => { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + let mut pairs = HashMap::new(); + pairs.insert("GEMINI_API_KEY".to_string(), api_key.clone()); + pairs.insert("GOOGLE_GEMINI_BASE_URL".to_string(), base_url.clone()); + pairs.insert("GEMINI_MODEL".to_string(), model.clone()); + Self::write_env_pairs(&env_path, &pairs)?; + } + (None, Some(env_raw)) => { + fs::write(&env_path, env_raw)?; + let settings = Value::Object(Map::new()); + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + (None, None) => { + // 缺失原始快照时回退到当前逻辑,保证核心字段存在 + Self::apply_gemini_config(tool, &api_key, &base_url, Some(&model))?; + } } - } - } - // 读取当前 .env(保留其他字段) - let mut env_vars = HashMap::new(); - if active_env.exists() { - let content = fs::read_to_string(&active_env)?; - for line in content.lines() { - let trimmed = line.trim(); - if !trimmed.is_empty() && !trimmed.starts_with('#') { - if let Some((key, value)) = trimmed.split_once('=') { - env_vars.insert(key.trim().to_string(), value.trim().to_string()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [&settings_path, &env_path] { + if path.exists() { + let metadata = fs::metadata(path)?; + let mut perms = metadata.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + } } } } + _ => anyhow::bail!("配置内容与工具不匹配: {}", tool.id), } - // 只更新 API 相关字段 - env_vars.insert("GEMINI_API_KEY".to_string(), backup_api_key); - env_vars.insert("GOOGLE_GEMINI_BASE_URL".to_string(), backup_base_url); - env_vars.insert("GEMINI_MODEL".to_string(), backup_model); - - // 写回 .env(保留其他字段) - let env_content: Vec = env_vars.iter().map(|(k, v)| format!("{k}={v}")).collect(); - fs::write(&active_env, env_content.join("\n") + "\n")?; - + let checksum = Self::compute_native_checksum(tool); + let state = ActiveProfileState { + profile_name: Some(profile_name.to_string()), + native_checksum: checksum, + last_synced_at: Some(Utc::now()), + dirty: false, + }; + save_active_state(&tool.id, &state)?; Ok(()) } /// 删除配置 pub fn delete_profile(tool: &Tool, profile_name: &str) -> Result<()> { - match tool.id.as_str() { - "claude-code" => { - let backup_path = tool.backup_path(profile_name); - if backup_path.exists() { - fs::remove_file(backup_path)?; - } + MigrationService::run_if_needed(); + delete_stored_profile(&tool.id, profile_name)?; + if let Ok(Some(mut state)) = read_active_state(&tool.id) { + if state.profile_name.as_deref() == Some(profile_name) { + state.profile_name = None; + state.dirty = true; + state.last_synced_at = Some(Utc::now()); + let _ = save_active_state(&tool.id, &state); } - "codex" => { - let backup_config = tool.config_dir.join(format!("config.{profile_name}.toml")); - let backup_auth = tool.config_dir.join(format!("auth.{profile_name}.json")); - - if backup_config.exists() { - fs::remove_file(backup_config)?; - } - if backup_auth.exists() { - fs::remove_file(backup_auth)?; - } - } - "gemini-cli" => { - let backup_env = tool.config_dir.join(format!(".env.{profile_name}")); - - if backup_env.exists() { - fs::remove_file(backup_env)?; - } - // 注意:不再删除 settings.json 备份,因为新版本不再备份它 - } - _ => anyhow::bail!("未知工具: {}", tool.id), } - Ok(()) } @@ -959,8 +1368,25 @@ impl ConfigService { Ok(settings) } + /// 读取 Claude Code 附属 config.json + pub fn read_claude_extra_config() -> Result { + let tool = Tool::claude_code(); + let extra_path = tool.config_dir.join("config.json"); + if !extra_path.exists() { + return Ok(Value::Object(Map::new())); + } + let content = + fs::read_to_string(&extra_path).context("读取 Claude Code config.json 失败")?; + if content.trim().is_empty() { + return Ok(Value::Object(Map::new())); + } + let json: Value = serde_json::from_str(&content) + .map_err(|err| anyhow!("解析 Claude Code config.json 失败: {err}"))?; + Ok(json) + } + /// 保存 Claude Code 完整配置 - pub fn save_claude_settings(settings: &Value) -> Result<()> { + pub fn save_claude_settings(settings: &Value, extra_config: Option<&Value>) -> Result<()> { if !settings.is_object() { anyhow::bail!("Claude Code 配置必须是 JSON 对象"); } @@ -968,11 +1394,20 @@ impl ConfigService { let tool = Tool::claude_code(); let config_dir = &tool.config_dir; let config_path = config_dir.join(&tool.config_file); + let extra_config_path = config_dir.join("config.json"); fs::create_dir_all(config_dir).context("创建 Claude Code 配置目录失败")?; let json = serde_json::to_string_pretty(settings)?; fs::write(&config_path, json).context("写入 Claude Code 配置失败")?; + if let Some(extra) = extra_config { + if !extra.is_object() { + anyhow::bail!("Claude Code config.json 必须是 JSON 对象"); + } + fs::write(&extra_config_path, serde_json::to_string_pretty(extra)?) + .context("写入 Claude Code config.json 失败")?; + } + #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -980,8 +1415,40 @@ impl ConfigService { let mut perms = metadata.permissions(); perms.set_mode(0o600); fs::set_permissions(&config_path, perms)?; + + if extra_config_path.exists() { + let metadata = fs::metadata(&extra_config_path)?; + let mut perms = metadata.permissions(); + perms.set_mode(0o600); + fs::set_permissions(&extra_config_path, perms)?; + } } + let env_obj = settings + .get("env") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + let api_key = env_obj + .get("ANTHROPIC_AUTH_TOKEN") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let base_url = env_obj + .get("ANTHROPIC_BASE_URL") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let profile_name = Self::profile_name_for_sync(&tool.id); + let (raw_settings, raw_config_json) = Self::read_claude_raw(&tool); + let payload = ProfilePayload::Claude { + api_key, + base_url, + raw_settings, + raw_config_json, + }; + Self::persist_payload_for_tool(&tool, &profile_name, &payload)?; + Ok(()) } @@ -1039,6 +1506,7 @@ impl ConfigService { let config_path = tool.config_dir.join(&tool.config_file); let auth_path = tool.config_dir.join("auth.json"); fs::create_dir_all(&tool.config_dir).context("创建 Codex 配置目录失败")?; + let mut final_auth_token = auth_token.clone(); let mut existing_doc = if config_path.exists() { let content = @@ -1060,6 +1528,7 @@ impl ConfigService { fs::write(&config_path, existing_doc.to_string()).context("写入 Codex config.toml 失败")?; if let Some(token) = auth_token { + final_auth_token = Some(token.clone()); let mut auth_data = if auth_path.exists() { let content = fs::read_to_string(&auth_path).unwrap_or_default(); serde_json::from_str::(&content).unwrap_or(Value::Object(Map::new())) @@ -1093,6 +1562,43 @@ impl ConfigService { fs::set_permissions(&config_path, perms)?; } + if final_auth_token.is_none() && auth_path.exists() { + if let Ok(content) = fs::read_to_string(&auth_path) { + if let Ok(auth) = serde_json::from_str::(&content) { + final_auth_token = auth + .get("OPENAI_API_KEY") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + } + + let provider_name = config + .get("model_provider") + .and_then(|v| v.as_str()) + .unwrap_or("duckcoding") + .to_string(); + let base_url = config + .get("model_providers") + .and_then(|v| v.as_object()) + .and_then(|providers| providers.get(&provider_name)) + .and_then(|provider| provider.get("base_url")) + .and_then(|v| v.as_str()) + .unwrap_or("https://jp.duckcoding.com/v1") + .to_string(); + + let api_key_for_store = final_auth_token.unwrap_or_default(); + let profile_name = Self::profile_name_for_sync(&tool.id); + let (raw_config_toml, raw_auth_json) = Self::read_codex_raw(&tool); + let payload = ProfilePayload::Codex { + api_key: api_key_for_store, + base_url: base_url.clone(), + provider: Some(provider_name), + raw_config_toml, + raw_auth_json, + }; + Self::persist_payload_for_tool(&tool, &profile_name, &payload)?; + Ok(()) } @@ -1171,6 +1677,21 @@ impl ConfigService { } } + let profile_name = Self::profile_name_for_sync(&tool.id); + let (raw_settings, raw_env) = Self::read_gemini_raw(&tool); + let payload = ProfilePayload::Gemini { + api_key: env.api_key.clone(), + base_url: env.base_url.clone(), + model: if env.model.trim().is_empty() { + "gemini-2.5-pro".to_string() + } else { + env.model.clone() + }, + raw_settings, + raw_env, + }; + Self::persist_payload_for_tool(&tool, &profile_name, &payload)?; + Ok(()) } @@ -1240,4 +1761,364 @@ impl ConfigService { fs::write(path, content)?; Ok(()) } + + fn read_codex_raw(tool: &Tool) -> (Option, Option) { + let config_path = tool.config_dir.join(&tool.config_file); + let auth_path = tool.config_dir.join("auth.json"); + let raw_config_toml = fs::read_to_string(&config_path).ok(); + let raw_auth_json = fs::read_to_string(&auth_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + (raw_config_toml, raw_auth_json) + } + + fn read_claude_raw(tool: &Tool) -> (Option, Option) { + let settings_path = tool.config_dir.join(&tool.config_file); + let extra_config_path = tool.config_dir.join("config.json"); + let raw_settings = fs::read_to_string(&settings_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + let raw_config_json = fs::read_to_string(&extra_config_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + (raw_settings, raw_config_json) + } + + fn read_gemini_raw(tool: &Tool) -> (Option, Option) { + let settings_path = tool.config_dir.join(&tool.config_file); + let env_path = tool.config_dir.join(".env"); + let raw_settings = fs::read_to_string(&settings_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + let raw_env = fs::read_to_string(&env_path).ok(); + (raw_settings, raw_env) + } + + fn profile_name_for_sync(tool_id: &str) -> String { + read_active_state(tool_id) + .ok() + .flatten() + .and_then(|state| state.profile_name) + .unwrap_or_else(|| "default".to_string()) + } + + fn persist_payload_for_tool( + tool: &Tool, + profile_name: &str, + payload: &ProfilePayload, + ) -> Result<()> { + save_profile_payload(&tool.id, profile_name, payload)?; + let checksum = Self::compute_native_checksum(tool); + let state = ActiveProfileState { + profile_name: Some(profile_name.to_string()), + native_checksum: checksum, + last_synced_at: Some(Utc::now()), + dirty: false, + }; + save_active_state(&tool.id, &state)?; + Ok(()) + } + + /// 返回参与同步/监听的配置文件列表(包含主配置和附属文件)。 + pub(crate) fn config_paths(tool: &Tool) -> Vec { + let mut paths = vec![tool.config_dir.join(&tool.config_file)]; + match tool.id.as_str() { + "codex" => { + paths.push(tool.config_dir.join("auth.json")); + } + "gemini-cli" => { + paths.push(tool.config_dir.join(".env")); + } + "claude-code" => { + paths.push(tool.config_dir.join("config.json")); + } + _ => {} + } + paths + } + + /// 计算配置文件组合哈希,任一文件变动都会改变结果。 + pub(crate) fn compute_native_checksum(tool: &Tool) -> Option { + use sha2::{Digest, Sha256}; + let mut paths = Self::config_paths(tool); + paths.sort(); + + let mut hasher = Sha256::new(); + let mut any_exists = false; + for path in paths { + hasher.update(path.to_string_lossy().as_bytes()); + if path.exists() { + any_exists = true; + match fs::read(&path) { + Ok(content) => hasher.update(&content), + Err(_) => return None, + } + } else { + hasher.update(b"MISSING"); + } + } + + if any_exists { + Some(format!("{:x}", hasher.finalize())) + } else { + None + } + } + + fn read_payload_from_native(tool: &Tool) -> Result { + match tool.id.as_str() { + "claude-code" => { + let settings = Self::read_claude_settings()?; + let env = settings + .get("env") + .and_then(|v| v.as_object()) + .ok_or_else(|| anyhow!("配置缺少 env 字段"))?; + let api_key = env + .get("ANTHROPIC_AUTH_TOKEN") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let base_url = env + .get("ANTHROPIC_BASE_URL") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + if api_key.is_empty() || base_url.is_empty() { + anyhow::bail!("原生配置缺少 API Key 或 Base URL"); + } + + let extra_config = fs::read_to_string(tool.config_dir.join("config.json")) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()); + + Ok(ProfilePayload::Claude { + api_key, + base_url, + raw_settings: Some(settings), + raw_config_json: extra_config, + }) + } + "codex" => { + let config_path = tool.config_dir.join(&tool.config_file); + if !config_path.exists() { + anyhow::bail!("未找到原生配置文件: {:?}", config_path); + } + let content = fs::read_to_string(&config_path) + .with_context(|| format!("读取 Codex 配置失败: {config_path:?}"))?; + let raw_config_toml = Some(content.clone()); + let toml_value: toml::Value = + toml::from_str(&content).context("解析 Codex config.toml 失败")?; + + let mut provider = toml_value + .get("model_provider") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let mut base_url = String::new(); + if let Some(providers) = + toml_value.get("model_providers").and_then(|v| v.as_table()) + { + if provider.is_none() { + provider = providers.keys().next().cloned(); + } + if let Some(provider_name) = provider.clone() { + if let Some(toml::Value::Table(table)) = providers.get(&provider_name) { + if let Some(toml::Value::String(url)) = table.get("base_url") { + base_url = url.clone(); + } + } + } + } + if base_url.is_empty() { + base_url = "https://jp.duckcoding.com/v1".to_string(); + } + + let auth_path = tool.config_dir.join("auth.json"); + let mut api_key = String::new(); + let mut raw_auth_json = None; + if auth_path.exists() { + let auth_content = + fs::read_to_string(&auth_path).context("读取 Codex auth.json 失败")?; + let auth: Value = + serde_json::from_str(&auth_content).context("解析 Codex auth.json 失败")?; + raw_auth_json = Some(auth.clone()); + api_key = auth + .get("OPENAI_API_KEY") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + } + if api_key.is_empty() { + anyhow::bail!("auth.json 缺少 OPENAI_API_KEY"); + } + + Ok(ProfilePayload::Codex { + api_key, + base_url, + provider, + raw_config_toml, + raw_auth_json, + }) + } + "gemini-cli" => { + let env_path = tool.config_dir.join(".env"); + if !env_path.exists() { + anyhow::bail!("未找到 Gemini CLI .env 配置: {:?}", env_path); + } + let raw_env = fs::read_to_string(&env_path).ok(); + let env_pairs = Self::read_env_pairs(&env_path)?; + let api_key = env_pairs + .get("GEMINI_API_KEY") + .cloned() + .unwrap_or_default() + .trim() + .to_string(); + let mut base_url = env_pairs + .get("GOOGLE_GEMINI_BASE_URL") + .cloned() + .unwrap_or_default(); + let model = env_pairs + .get("GEMINI_MODEL") + .cloned() + .unwrap_or_else(|| "gemini-2.5-pro".to_string()); + + if base_url.trim().is_empty() { + base_url = "https://generativelanguage.googleapis.com".to_string(); + } + if api_key.is_empty() { + anyhow::bail!(".env 缺少 GEMINI_API_KEY"); + } + + Ok(ProfilePayload::Gemini { + api_key, + base_url, + model, + raw_settings: fs::read_to_string(tool.config_dir.join(&tool.config_file)) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()), + raw_env, + }) + } + other => anyhow::bail!("暂不支持的工具: {other}"), + } + } + + /// 将外部修改导入集中仓,并刷新激活状态。 + pub fn import_external_change( + tool: &Tool, + profile_name: &str, + as_new: bool, + ) -> Result { + MigrationService::run_if_needed(); + + let target_profile = profile_name.trim(); + if target_profile.is_empty() { + anyhow::bail!("profile 名称不能为空"); + } + let existing = list_stored_profiles(&tool.id)?; + let exists = existing.iter().any(|p| p == target_profile); + if as_new && exists { + anyhow::bail!("profile 已存在: {target_profile}"); + } + + let payload = Self::read_payload_from_native(tool)?; + let checksum_before = Self::compute_native_checksum(tool); + save_profile_payload(&tool.id, target_profile, &payload)?; + + let checksum = Self::compute_native_checksum(tool); + let replaced = !as_new && exists; + let state = ActiveProfileState { + profile_name: Some(target_profile.to_string()), + native_checksum: checksum.clone(), + last_synced_at: Some(Utc::now()), + dirty: false, + }; + save_active_state(&tool.id, &state)?; + + Ok(ImportExternalChangeResult { + profile_name: target_profile.to_string(), + was_new: as_new, + replaced, + before_checksum: checksum_before, + checksum, + }) + } + + /// 扫描原生配置是否被外部修改,返回差异列表,并将 dirty 标记写入 active_state。 + pub fn detect_external_changes() -> Result> { + let mut changes = Vec::new(); + for tool in Tool::all() { + let current_checksum = Self::compute_native_checksum(&tool); + let mut state = read_active_state(&tool.id)?.unwrap_or_default(); + let last_checksum = state.native_checksum.clone(); + if last_checksum != current_checksum { + // 标记脏,但保留旧 checksum 以便前端确认后再更新 + state.dirty = true; + save_active_state(&tool.id, &state)?; + + changes.push(ExternalConfigChange { + tool_id: tool.id.clone(), + path: tool + .config_dir + .join(&tool.config_file) + .to_string_lossy() + .to_string(), + checksum: current_checksum.clone(), + detected_at: Utc::now(), + dirty: true, + }); + } else if state.dirty { + // 仍在脏状态时保持报告 + changes.push(ExternalConfigChange { + tool_id: tool.id.clone(), + path: tool + .config_dir + .join(&tool.config_file) + .to_string_lossy() + .to_string(), + checksum: current_checksum.clone(), + detected_at: Utc::now(), + dirty: true, + }); + } + } + Ok(changes) + } + + /// 直接标记外部修改(用于事件监听场景)。 + pub fn mark_external_change( + tool: &Tool, + path: std::path::PathBuf, + checksum: Option, + ) -> Result { + let mut state = read_active_state(&tool.id)?.unwrap_or_default(); + // 若与当前记录的 checksum 一致,则视为内部写入,保持非脏状态 + let checksum_changed = state.native_checksum != checksum; + state.dirty = checksum_changed; + state.native_checksum = checksum.clone(); + save_active_state(&tool.id, &state)?; + + Ok(ExternalConfigChange { + tool_id: tool.id.clone(), + path: path.to_string_lossy().to_string(), + checksum, + detected_at: Utc::now(), + dirty: state.dirty, + }) + } + + /// 确认/清除外部修改状态,刷新 checksum。 + pub fn acknowledge_external_change(tool: &Tool) -> Result<()> { + let current_checksum = Self::compute_native_checksum(tool); + + let mut state = read_active_state(&tool.id)?.unwrap_or_default(); + state.dirty = false; + state.native_checksum = current_checksum; + state.last_synced_at = Some(Utc::now()); + save_active_state(&tool.id, &state)?; + Ok(()) + } } diff --git a/src-tauri/src/services/migration.rs b/src-tauri/src/services/migration.rs new file mode 100644 index 0000000..426f543 --- /dev/null +++ b/src-tauri/src/services/migration.rs @@ -0,0 +1,562 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use chrono::Utc; +use once_cell::sync::OnceCell; +use serde::Serialize; +use serde_json::{Map, Value}; +use toml; + +use crate::models::Tool; +use crate::services::profile_store::{ + migration_log_path, profile_extension, profile_file_path, save_profile_payload, + MigrationRecord, ProfilePayload, +}; + +#[derive(Debug, Clone, Serialize)] +pub struct LegacyCleanupResult { + pub tool_id: String, + pub removed: Vec, + pub failed: Vec<(PathBuf, String)>, +} + +/// 旧版配置迁移到集中存储的轻量实现(仅搬运,不删除旧文件) +pub struct MigrationService; + +impl MigrationService { + /// 仅执行一次的迁移入口,失败时记录日志但不阻塞主流程。 + pub fn run_if_needed() { + static ONCE: OnceCell<()> = OnceCell::new(); + let _ = ONCE.get_or_init(|| { + if let Err(err) = Self::migrate_all() { + eprintln!("迁移旧配置失败: {err:?}"); + } + }); + } + + /// 测试专用:直接执行完整迁移流程(不受 ONCE 限制) + #[cfg(test)] + pub fn run_for_tests() -> Result> { + let mut records = Vec::new(); + records.extend(Self::migrate_claude()?); + records.extend(Self::migrate_codex()?); + records.extend(Self::migrate_gemini()?); + if !records.is_empty() { + Self::append_log(&records)?; + } + Ok(records) + } + + fn migrate_all() -> Result<()> { + let mut records = Vec::new(); + records.extend(Self::migrate_claude()?); + records.extend(Self::migrate_codex()?); + records.extend(Self::migrate_gemini()?); + + if !records.is_empty() { + Self::append_log(&records)?; + } + Ok(()) + } + + fn migrate_claude() -> Result> { + let tool = Tool::claude_code(); + let mut records = Vec::new(); + + if !tool.config_dir.exists() { + return Ok(records); + } + + for entry in fs::read_dir(&tool.config_dir)? { + let entry = entry?; + let path = entry.path(); + let name = match path.file_name().and_then(|s| s.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + + if name == tool.config_file { + continue; + } + if !(name.starts_with("settings.") && name.ends_with(".json")) { + continue; + } + + let profile = name + .trim_start_matches("settings.") + .trim_end_matches(".json") + .to_string(); + if profile.is_empty() || profile.starts_with('.') { + continue; + } + + let now = Utc::now(); + let record = match fs::read_to_string(&path) { + Ok(content) => { + let data: Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(err) => { + records.push(MigrationRecord { + tool_id: tool.id.clone(), + profile_name: profile.clone(), + from_path: path.clone(), + to_path: PathBuf::new(), + succeeded: false, + message: Some(format!("解析失败: {err}")), + timestamp: now, + }); + continue; + } + }; + + let api_key = data + .get("ANTHROPIC_AUTH_TOKEN") + .and_then(|v| v.as_str()) + .or_else(|| { + data.get("env") + .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) + .and_then(|v| v.as_str()) + }) + .unwrap_or("") + .to_string(); + let base_url = data + .get("ANTHROPIC_BASE_URL") + .and_then(|v| v.as_str()) + .or_else(|| { + data.get("env") + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(|v| v.as_str()) + }) + .unwrap_or("") + .to_string(); + + let payload = ProfilePayload::Claude { + api_key, + base_url, + raw_settings: Some(data.clone()), + raw_config_json: None, + }; + let to_path = + profile_file_path(&tool.id, &profile, profile_extension(&tool.id))?; + save_profile_payload(&tool.id, &profile, &payload)?; + let _ = fs::remove_file(&path); + + MigrationRecord { + tool_id: tool.id.clone(), + profile_name: profile.clone(), + from_path: path.clone(), + to_path, + succeeded: true, + message: None, + timestamp: now, + } + } + Err(err) => MigrationRecord { + tool_id: tool.id.clone(), + profile_name: profile.clone(), + from_path: path.clone(), + to_path: PathBuf::new(), + succeeded: false, + message: Some(format!("读取失败: {err}")), + timestamp: now, + }, + }; + + records.push(record); + } + + Ok(records) + } + + fn migrate_codex() -> Result> { + let tool = Tool::codex(); + let mut records = Vec::new(); + + if !tool.config_dir.exists() { + return Ok(records); + } + + for entry in fs::read_dir(&tool.config_dir)? { + let entry = entry?; + let path = entry.path(); + let name = match path.file_name().and_then(|s| s.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + + if !(name.starts_with("config.") && name.ends_with(".toml")) { + continue; + } + + let profile = name + .trim_start_matches("config.") + .trim_end_matches(".toml") + .to_string(); + if profile.is_empty() || profile.starts_with('.') { + continue; + } + + let backup_auth = tool.config_dir.join(format!("auth.{profile}.json")); + if !backup_auth.exists() { + continue; + } + + let now = Utc::now(); + let auth_content = fs::read_to_string(&backup_auth).unwrap_or_default(); + let backup_auth_data: Value = + serde_json::from_str(&auth_content).unwrap_or(Value::Object(Map::new())); + let api_key = backup_auth_data + .get("OPENAI_API_KEY") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let mut base_url = String::new(); + let mut provider = None; + let raw_config_toml = fs::read_to_string(&path).ok(); + if let Some(content) = raw_config_toml.clone() { + if let Ok(toml::Value::Table(table)) = toml::from_str::(&content) { + provider = table + .get("model_provider") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if let Some(toml::Value::Table(providers)) = table.get("model_providers") { + if let Some(provider_name) = provider + .clone() + .or_else(|| providers.keys().next().cloned()) + { + if let Some(toml::Value::Table(provider_table)) = + providers.get(&provider_name) + { + if let Some(toml::Value::String(url)) = + provider_table.get("base_url") + { + base_url = url.clone(); + } + } + } + } + } + } + + if base_url.is_empty() { + base_url = "https://jp.duckcoding.com/v1".to_string(); + } + + let payload = ProfilePayload::Codex { + api_key, + base_url: base_url.clone(), + provider: provider.clone(), + raw_config_toml, + raw_auth_json: Some(backup_auth_data.clone()), + }; + let to_path = profile_file_path(&tool.id, &profile, profile_extension(&tool.id))?; + save_profile_payload(&tool.id, &profile, &payload)?; + let _ = fs::remove_file(&path); + let _ = fs::remove_file(&backup_auth); + + records.push(MigrationRecord { + tool_id: tool.id.clone(), + profile_name: profile, + from_path: path.clone(), + to_path, + succeeded: true, + message: None, + timestamp: now, + }); + } + + Ok(records) + } + + fn migrate_gemini() -> Result> { + let tool = Tool::gemini_cli(); + let mut records = Vec::new(); + + if !tool.config_dir.exists() { + return Ok(records); + } + + for entry in fs::read_dir(&tool.config_dir)? { + let entry = entry?; + let path = entry.path(); + let name = match path.file_name().and_then(|s| s.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + + if !(name.starts_with(".env.") && name.len() > 5) { + continue; + } + + let profile = name.trim_start_matches(".env.").to_string(); + if profile.is_empty() || profile.starts_with('.') { + continue; + } + + let now = Utc::now(); + let mut api_key = String::new(); + let mut base_url = String::new(); + let mut model = "gemini-2.5-pro".to_string(); + let raw_env = fs::read_to_string(&path).ok(); + + if let Some(content) = raw_env.clone() { + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some((key, value)) = trimmed.split_once('=') { + match key.trim() { + "GEMINI_API_KEY" => api_key = value.trim().to_string(), + "GOOGLE_GEMINI_BASE_URL" => base_url = value.trim().to_string(), + "GEMINI_MODEL" => model = value.trim().to_string(), + _ => {} + } + } + } + } + + if base_url.is_empty() { + base_url = "https://generativelanguage.googleapis.com".to_string(); + } + + let payload = ProfilePayload::Gemini { + api_key, + base_url, + model, + raw_settings: None, + raw_env, + }; + let to_path = profile_file_path(&tool.id, &profile, profile_extension(&tool.id))?; + save_profile_payload(&tool.id, &profile, &payload)?; + let _ = fs::remove_file(&path); + + records.push(MigrationRecord { + tool_id: tool.id.clone(), + profile_name: profile, + from_path: path.clone(), + to_path, + succeeded: true, + message: None, + timestamp: now, + }); + } + + Ok(records) + } + + fn append_log(records: &[MigrationRecord]) -> Result<()> { + let log_path = migration_log_path()?; + let mut existing: Vec = if log_path.exists() { + let content = fs::read_to_string(&log_path)?; + serde_json::from_str(&content).unwrap_or_default() + } else { + vec![] + }; + existing.extend_from_slice(records); + let json = serde_json::to_string_pretty(&existing)?; + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&log_path, json).with_context(|| format!("写入迁移日志失败: {log_path:?}"))?; + Ok(()) + } + + /// 清理遗留的旧版备份文件,返回删除/失败列表。 + pub fn cleanup_legacy_backups() -> Result> { + Ok(vec![ + Self::cleanup_claude_backups()?, + Self::cleanup_codex_backups()?, + Self::cleanup_gemini_backups()?, + ]) + } + + fn cleanup_claude_backups() -> Result { + let tool = Tool::claude_code(); + let mut removed = Vec::new(); + let mut failed = Vec::new(); + if tool.config_dir.exists() { + for entry in fs::read_dir(&tool.config_dir)? { + let entry = entry?; + let path = entry.path(); + let name = match path.file_name().and_then(|s| s.to_str()) { + Some(n) => n, + None => continue, + }; + if name == tool.config_file { + continue; + } + if name.starts_with("settings.") && name.ends_with(".json") { + match fs::remove_file(&path) { + Ok(_) => removed.push(path.clone()), + Err(err) => failed.push((path.clone(), err.to_string())), + } + } + } + } + Ok(LegacyCleanupResult { + tool_id: tool.id, + removed, + failed, + }) + } + + fn cleanup_codex_backups() -> Result { + let tool = Tool::codex(); + let mut removed = Vec::new(); + let mut failed = Vec::new(); + if tool.config_dir.exists() { + for entry in fs::read_dir(&tool.config_dir)? { + let entry = entry?; + let path = entry.path(); + let name = match path.file_name().and_then(|s| s.to_str()) { + Some(n) => n, + None => continue, + }; + + let is_backup_config = name != tool.config_file + && name.starts_with("config.") + && name.ends_with(".toml"); + let is_backup_auth = + name != "auth.json" && name.starts_with("auth.") && name.ends_with(".json"); + + if is_backup_config || is_backup_auth { + match fs::remove_file(&path) { + Ok(_) => removed.push(path.clone()), + Err(err) => failed.push((path.clone(), err.to_string())), + } + } + } + } + Ok(LegacyCleanupResult { + tool_id: tool.id, + removed, + failed, + }) + } + + fn cleanup_gemini_backups() -> Result { + let tool = Tool::gemini_cli(); + let mut removed = Vec::new(); + let mut failed = Vec::new(); + + if tool.config_dir.exists() { + for entry in fs::read_dir(&tool.config_dir)? { + let entry = entry?; + let path = entry.path(); + let name = match path.file_name().and_then(|s| s.to_str()) { + Some(n) => n, + None => continue, + }; + + if name == ".env" || name == tool.config_file { + continue; + } + + if name.starts_with(".env.") { + match fs::remove_file(&path) { + Ok(_) => removed.push(path.clone()), + Err(err) => failed.push((path.clone(), err.to_string())), + } + } + } + } + + Ok(LegacyCleanupResult { + tool_id: tool.id, + removed, + failed, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::env; + use tempfile::TempDir; + + #[test] + #[serial] + fn migrate_all_tools_and_write_log() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + env::set_var("DUCKCODING_CONFIG_DIR", temp.path()); + env::set_var("HOME", temp.path()); + env::set_var("USERPROFILE", temp.path()); + + // Claude legacy file + let claude_dir = temp.path().join(".claude"); + fs::create_dir_all(&claude_dir)?; + fs::write( + claude_dir.join("settings.personal.json"), + r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"claude-key","ANTHROPIC_BASE_URL":"https://claude"}}"#, + )?; + + // Codex legacy files + let codex_dir = temp.path().join(".codex"); + fs::create_dir_all(&codex_dir)?; + fs::write( + codex_dir.join("config.work.toml"), + r#"model_provider="duckcoding" +[model_providers.duckcoding] +base_url="https://duckcoding.test/v1" +"#, + )?; + fs::write( + codex_dir.join("auth.work.json"), + r#"{"OPENAI_API_KEY":"codex-key"}"#, + )?; + + // Gemini legacy file + let gemini_dir = temp.path().join(".gemini"); + fs::create_dir_all(&gemini_dir)?; + fs::write( + gemini_dir.join(".env.dev"), + "GEMINI_API_KEY=gem-key\nGOOGLE_GEMINI_BASE_URL=https://gem\nGEMINI_MODEL=gem-model\n", + )?; + + let records = MigrationService::run_for_tests()?; + assert_eq!(records.len(), 3); + + // 确认迁移输出存在 + let claude_profile = + profile_file_path("claude-code", "personal", profile_extension("claude-code"))?; + let codex_profile = profile_file_path("codex", "work", profile_extension("codex"))?; + let gemini_profile = + profile_file_path("gemini-cli", "dev", profile_extension("gemini-cli"))?; + assert!(claude_profile.exists()); + assert!(codex_profile.exists()); + assert!(gemini_profile.exists()); + + // 日志写入 + let log_records = crate::services::profile_store::read_migration_log()?; + assert_eq!(log_records.len(), 3); + Ok(()) + } + + #[test] + #[serial] + fn cleanup_legacy_backups_removes_files() -> Result<()> { + let temp = TempDir::new().expect("create temp dir"); + env::set_var("DUCKCODING_CONFIG_DIR", temp.path()); + env::set_var("HOME", temp.path()); + env::set_var("USERPROFILE", temp.path()); + + let claude_dir = temp.path().join(".claude"); + fs::create_dir_all(&claude_dir)?; + let stale = claude_dir.join("settings.old.json"); + fs::write(&stale, "{}")?; + + let results = MigrationService::cleanup_legacy_backups()?; + let claude_result = results + .into_iter() + .find(|r| r.tool_id == "claude-code") + .expect("claude result"); + assert!(claude_result.removed.contains(&stale)); + + Ok(()) + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 7a953a6..9f47862 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -8,6 +8,8 @@ // - session: 会话管理(透明代理请求追踪) pub mod config; +pub mod migration; +pub mod profile_store; pub mod proxy; pub mod session; pub mod tool; @@ -15,6 +17,9 @@ pub mod update; // 重新导出服务 pub use config::*; +pub use config_watcher::*; +pub use migration::*; +pub use profile_store::*; pub use proxy::*; // session 模块:明确导出避免 db 名称冲突 pub use session::{manager::SESSION_MANAGER, models::*}; diff --git a/src-tauri/src/services/profile_store.rs b/src-tauri/src/services/profile_store.rs new file mode 100644 index 0000000..a2cd178 --- /dev/null +++ b/src-tauri/src/services/profile_store.rs @@ -0,0 +1,548 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::utils::config; + +/// 集中配置中心目录结构: +/// - ~/.duckcoding/profiles/{tool}/{profile}.{ext} +/// - ~/.duckcoding/active/{tool}.json +/// - ~/.duckcoding/metadata/index.json +/// - 后续模块将基于这些路径进行统一读写与监听。 +const PROFILES_DIR: &str = "profiles"; +const ACTIVE_DIR: &str = "active"; +const METADATA_DIR: &str = "metadata"; +const INDEX_FILE: &str = "index.json"; +const MIGRATION_LOG: &str = "migration.log.json"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum ProfileFormat { + Json, + Toml, + Env, + #[default] + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ProfileSource { + #[default] + Local, + Imported, + ExternalChange, + Migrated, + Generated, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ActiveProfileState { + pub profile_name: Option, + pub native_checksum: Option, + pub last_synced_at: Option>, + pub dirty: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProfileDescriptor { + pub tool_id: String, + pub name: String, + #[serde(default)] + pub format: ProfileFormat, + pub path: PathBuf, + #[serde(default)] + pub updated_at: Option>, + #[serde(default)] + pub created_at: Option>, + #[serde(default)] + pub source: ProfileSource, + #[serde(default)] + pub checksum: Option, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProfileIndex { + /// tool_id -> descriptors + pub entries: HashMap>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MigrationRecord { + pub tool_id: String, + pub profile_name: String, + pub from_path: PathBuf, + pub to_path: PathBuf, + pub succeeded: bool, + pub message: Option, + pub timestamp: DateTime, +} + +/// 返回集中配置根目录(确保存在) +pub fn center_root() -> Result { + let base = config::config_dir().map_err(|e| anyhow!(e))?; + fs::create_dir_all(&base).context("创建 ~/.duckcoding 失败")?; + Ok(base) +} + +pub fn profiles_root() -> Result { + let root = center_root()?; + let profiles = root.join(PROFILES_DIR); + fs::create_dir_all(&profiles).context("创建 profiles 目录失败")?; + Ok(profiles) +} + +pub fn tool_profiles_dir(tool_id: &str) -> Result { + let dir = profiles_root()?.join(tool_id); + fs::create_dir_all(&dir).with_context(|| format!("创建工具配置目录失败: {dir:?}"))?; + Ok(dir) +} + +pub fn active_state_path(tool_id: &str) -> Result { + let dir = center_root()?.join(ACTIVE_DIR); + fs::create_dir_all(&dir).context("创建 active 目录失败")?; + Ok(dir.join(format!("{tool_id}.json"))) +} + +pub fn metadata_index_path() -> Result { + let dir = center_root()?.join(METADATA_DIR); + fs::create_dir_all(&dir).context("创建 metadata 目录失败")?; + Ok(dir.join(INDEX_FILE)) +} + +pub fn migration_log_path() -> Result { + let dir = center_root()?.join(METADATA_DIR); + fs::create_dir_all(&dir).context("创建 metadata 目录失败")?; + Ok(dir.join(MIGRATION_LOG)) +} + +pub fn profile_file_path(tool_id: &str, profile_name: &str, ext: &str) -> Result { + let dir = tool_profiles_dir(tool_id)?; + Ok(dir.join(format!("{profile_name}.{ext}"))) +} + +/// 计算文件的 sha256 哈希,便于自写过滤或外部更改检测。 +pub fn file_checksum(path: &Path) -> Result { + use sha2::{Digest, Sha256}; + let content = fs::read(path).with_context(|| format!("读取文件失败: {path:?}"))?; + let mut hasher = Sha256::new(); + hasher.update(&content); + let digest = hasher.finalize(); + Ok(format!("{digest:x}")) +} + +pub fn read_migration_log() -> Result> { + let path = migration_log_path()?; + if !path.exists() { + return Ok(vec![]); + } + let content = fs::read_to_string(&path)?; + let records: Vec = serde_json::from_str(&content)?; + Ok(records) +} + +fn profile_index_path() -> Result { + metadata_index_path() +} + +fn load_index() -> Result { + let path = profile_index_path()?; + if !path.exists() { + return Ok(ProfileIndex::default()); + } + let content = + fs::read_to_string(&path).with_context(|| format!("读取元数据索引失败: {path:?}"))?; + let index: ProfileIndex = + serde_json::from_str(&content).with_context(|| format!("解析元数据索引失败: {path:?}"))?; + Ok(index) +} + +fn save_index(mut index: ProfileIndex) -> Result<()> { + let path = profile_index_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + // 按 tool_id 排序,便于 diff + let mut sorted_entries: Vec<_> = index.entries.into_iter().collect(); + sorted_entries.sort_by(|a, b| a.0.cmp(&b.0)); + index.entries = sorted_entries.into_iter().collect(); + let json = serde_json::to_string_pretty(&index).context("序列化元数据索引失败")?; + fs::write(&path, json).with_context(|| format!("写入元数据索引失败: {path:?}"))?; + Ok(()) +} + +fn upsert_descriptor(descriptor: ProfileDescriptor) -> Result<()> { + let mut index = load_index()?; + index + .entries + .entry(descriptor.tool_id.clone()) + .or_default() + .retain(|p| p.name != descriptor.name); + index + .entries + .entry(descriptor.tool_id.clone()) + .or_default() + .push(descriptor); + save_index(index) +} + +fn remove_descriptor(tool_id: &str, profile_name: &str) -> Result<()> { + let mut index = load_index()?; + if let Some(list) = index.entries.get_mut(tool_id) { + list.retain(|p| p.name != profile_name); + } + save_index(index) +} + +/// 读取元数据索引(供外部调用) +pub fn load_profile_index() -> Result { + load_index() +} + +/// 根据 tool_id 过滤描述,None 返回所有 +pub fn list_descriptors(tool_id: Option<&str>) -> Result> { + let index = load_index()?; + let mut all = Vec::new(); + match tool_id { + Some(id) => { + if let Some(list) = index.entries.get(id) { + all.extend(list.clone()); + } + } + None => { + for list in index.entries.values() { + all.extend(list.clone()); + } + } + } + Ok(all) +} + +pub fn profile_extension(tool_id: &str) -> &'static str { + match tool_id { + "gemini-cli" => "json", + "claude-code" => "json", + "codex" => "json", + _ => "json", + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "tool_id", rename_all = "kebab-case")] +pub enum ProfilePayload { + #[serde(rename = "claude-code")] + Claude { + api_key: String, + base_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + raw_settings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + raw_config_json: Option, + }, + #[serde(rename = "codex")] + Codex { + api_key: String, + base_url: String, + provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + raw_config_toml: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + raw_auth_json: Option, + }, + #[serde(rename = "gemini-cli")] + Gemini { + api_key: String, + base_url: String, + model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + raw_settings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + raw_env: Option, + }, +} + +impl ProfilePayload { + pub fn api_key(&self) -> &str { + match self { + ProfilePayload::Claude { api_key, .. } => api_key, + ProfilePayload::Codex { api_key, .. } => api_key, + ProfilePayload::Gemini { api_key, .. } => api_key, + } + } + + pub fn base_url(&self) -> &str { + match self { + ProfilePayload::Claude { base_url, .. } => base_url, + ProfilePayload::Codex { base_url, .. } => base_url, + ProfilePayload::Gemini { base_url, .. } => base_url, + } + } +} + +pub fn save_profile_payload( + tool_id: &str, + profile_name: &str, + payload: &ProfilePayload, +) -> Result<()> { + let ext = profile_extension(tool_id); + let path = profile_file_path(tool_id, profile_name, ext)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("创建配置目录失败: {parent:?}"))?; + } + + let now = Utc::now(); + let checksum_source = serde_json::to_string(payload).context("序列化配置用于校验失败")?; + let checksum = { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(checksum_source.as_bytes()); + format!("{:x}", hasher.finalize()) + }; + + let descriptor = ProfileDescriptor { + tool_id: tool_id.to_string(), + name: profile_name.to_string(), + format: ProfileFormat::Json, + path: path.clone(), + created_at: Some(now), + updated_at: Some(now), + source: ProfileSource::Local, + checksum: Some(checksum), + tags: vec![], + }; + + let content = serde_json::to_string_pretty(payload).context("序列化配置失败")?; + fs::write(&path, content).with_context(|| format!("写入集中配置失败: {path:?}"))?; + + upsert_descriptor(descriptor)?; + Ok(()) +} + +pub fn load_profile_payload(tool_id: &str, profile_name: &str) -> Result { + let ext = profile_extension(tool_id); + let path = profile_file_path(tool_id, profile_name, ext)?; + let content = fs::read_to_string(&path).with_context(|| format!("读取配置失败: {path:?}"))?; + let payload: ProfilePayload = + serde_json::from_str(&content).with_context(|| format!("解析配置失败: {path:?}"))?; + Ok(payload) +} + +pub fn list_profile_names(tool_id: &str) -> Result> { + let dir = tool_profiles_dir(tool_id)?; + let ext = profile_extension(tool_id); + let mut profiles = Vec::new(); + if dir.exists() { + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + let suffix = path.extension().and_then(|e| e.to_str()); + if suffix == Some(ext) { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + profiles.push(stem.to_string()); + } + } + } + } + profiles.sort(); + profiles.dedup(); + Ok(profiles) +} + +pub fn delete_profile(tool_id: &str, profile_name: &str) -> Result<()> { + let ext = profile_extension(tool_id); + let path = profile_file_path(tool_id, profile_name, ext)?; + if path.exists() { + fs::remove_file(&path).with_context(|| format!("删除配置失败: {path:?}"))?; + } + remove_descriptor(tool_id, profile_name)?; + Ok(()) +} + +pub fn read_active_state(tool_id: &str) -> Result> { + let path = active_state_path(tool_id)?; + if !path.exists() { + return Ok(None); + } + let content = + fs::read_to_string(&path).with_context(|| format!("读取激活状态失败: {path:?}"))?; + let state: ActiveProfileState = + serde_json::from_str(&content).with_context(|| format!("解析激活状态失败: {path:?}"))?; + Ok(Some(state)) +} + +pub fn save_active_state(tool_id: &str, state: &ActiveProfileState) -> Result<()> { + let path = active_state_path(tool_id)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(state).context("序列化激活状态失败")?; + fs::write(&path, json).with_context(|| format!("写入激活状态失败: {path:?}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use serial_test::serial; + use std::env; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TempConfigGuard { + path: PathBuf, + } + + impl Drop for TempConfigGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + env::remove_var("DUCKCODING_CONFIG_DIR"); + } + } + + fn setup_temp_dir() -> Result { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + let path = env::temp_dir().join(format!("duckcoding_test_{suffix}")); + if path.exists() { + fs::remove_dir_all(&path)?; + } + env::set_var("DUCKCODING_CONFIG_DIR", &path); + Ok(TempConfigGuard { path }) + } + + #[test] + #[serial] + fn save_and_load_profile_payload_roundtrip() -> Result<()> { + let _guard = setup_temp_dir()?; + let tool_id = "claude-code"; + let profile = "roundtrip"; + + let payload = ProfilePayload::Claude { + api_key: "test-key".to_string(), + base_url: "https://example.com".to_string(), + raw_settings: None, + raw_config_json: None, + }; + + save_profile_payload(tool_id, profile, &payload)?; + let names = list_profile_names(tool_id)?; + assert!(names.contains(&profile.to_string())); + + let loaded = load_profile_payload(tool_id, profile)?; + match loaded { + ProfilePayload::Claude { + api_key, base_url, .. + } => { + assert_eq!(api_key, "test-key"); + assert_eq!(base_url, "https://example.com"); + } + _ => panic!("unexpected payload variant"), + } + + let state = ActiveProfileState { + profile_name: Some(profile.to_string()), + native_checksum: Some("abc".to_string()), + last_synced_at: None, + dirty: false, + }; + save_active_state(tool_id, &state)?; + let loaded_state = read_active_state(tool_id)?.expect("state should exist"); + assert_eq!(loaded_state.profile_name, state.profile_name); + assert_eq!(loaded_state.native_checksum, state.native_checksum); + + Ok(()) + } + + #[test] + #[serial] + fn delete_profile_removes_file_and_descriptor() -> Result<()> { + let _guard = setup_temp_dir()?; + let tool_id = "codex"; + let profile = "temp"; + let payload = ProfilePayload::Codex { + api_key: "k1".to_string(), + base_url: "https://example.com".to_string(), + provider: Some("duckcoding".to_string()), + raw_config_toml: None, + raw_auth_json: None, + }; + + save_profile_payload(tool_id, profile, &payload)?; + assert!(list_profile_names(tool_id)?.contains(&profile.to_string())); + + delete_profile(tool_id, profile)?; + assert!(list_profile_names(tool_id)?.is_empty()); + + let descriptors = list_descriptors(Some(tool_id))?; + assert!( + descriptors.iter().all(|d| d.name != profile), + "descriptor should be removed after delete" + ); + Ok(()) + } + + #[test] + #[serial] + fn list_descriptors_and_index_roundtrip() -> Result<()> { + let _guard = setup_temp_dir()?; + let payload_claude = ProfilePayload::Claude { + api_key: "a".to_string(), + base_url: "https://a.com".to_string(), + raw_settings: None, + raw_config_json: None, + }; + let payload_gemini = ProfilePayload::Gemini { + api_key: "b".to_string(), + base_url: "https://b.com".to_string(), + model: "m".to_string(), + raw_settings: None, + raw_env: None, + }; + save_profile_payload("claude-code", "p1", &payload_claude)?; + save_profile_payload("gemini-cli", "g1", &payload_gemini)?; + + let descriptors = list_descriptors(None)?; + assert_eq!(descriptors.len(), 2); + assert!(descriptors + .iter() + .any(|d| d.tool_id == "claude-code" && d.name == "p1")); + assert!(descriptors + .iter() + .any(|d| d.tool_id == "gemini-cli" && d.name == "g1")); + + let index = load_profile_index()?; + assert!(index.entries.contains_key("claude-code")); + assert!(index.entries.contains_key("gemini-cli")); + Ok(()) + } + + #[test] + #[serial] + fn read_migration_log_returns_records() -> Result<()> { + let _guard = setup_temp_dir()?; + let log_path = migration_log_path()?; + let records = vec![MigrationRecord { + tool_id: "claude-code".to_string(), + profile_name: "p1".to_string(), + from_path: PathBuf::from("old"), + to_path: PathBuf::from("new"), + succeeded: true, + message: None, + timestamp: Utc::now(), + }]; + fs::write(&log_path, serde_json::to_string(&records)?)?; + + let loaded = read_migration_log()?; + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].profile_name, "p1"); + Ok(()) + } +} diff --git a/src-tauri/src/utils/config.rs b/src-tauri/src/utils/config.rs index 3bf44c0..6f76e36 100644 --- a/src-tauri/src/utils/config.rs +++ b/src-tauri/src/utils/config.rs @@ -5,6 +5,15 @@ use std::path::PathBuf; /// DuckCoding 配置目录 (~/.duckcoding),若不存在则创建 pub fn config_dir() -> Result { + if let Ok(override_dir) = std::env::var("DUCKCODING_CONFIG_DIR") { + let path = PathBuf::from(override_dir); + if !path.exists() { + fs::create_dir_all(&path) + .map_err(|e| format!("Failed to create config directory: {e}"))?; + } + return Ok(path); + } + let home_dir = dirs::home_dir().ok_or("Failed to get home directory")?; let config_dir = home_dir.join(".duckcoding"); if !config_dir.exists() { @@ -185,3 +194,35 @@ pub fn apply_proxy_if_configured() { ProxyService::apply_proxy_from_config(&config); } } + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::env; + use tempfile::TempDir; + + #[test] + #[serial] + fn config_dir_respects_env_override() { + let temp = TempDir::new().expect("create temp dir"); + env::set_var("DUCKCODING_CONFIG_DIR", temp.path()); + let dir = config_dir().expect("config_dir should succeed"); + assert_eq!(dir, temp.path()); + assert!(dir.exists()); + env::remove_var("DUCKCODING_CONFIG_DIR"); + } + + #[test] + #[serial] + fn config_dir_creates_when_missing() { + // use random temp child path to ensure it does not exist + let temp = TempDir::new().expect("create temp dir"); + let custom = temp.path().join("nested"); + env::set_var("DUCKCODING_CONFIG_DIR", &custom); + let dir = config_dir().expect("config_dir should create path"); + assert!(dir.exists()); + assert!(dir.ends_with("nested")); + env::remove_var("DUCKCODING_CONFIG_DIR"); + } +} From 5733e0cf76bb112f57c55aab7f59089352f3ff5c Mon Sep 17 00:00:00 2001 From: Wangnov Date: Tue, 2 Dec 2025 13:46:58 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat(watcher):=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=9B=91=E5=90=AC=E6=9C=8D=E5=8A=A1=E4=B8=8E=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/onboarding.rs | 2 + src-tauri/src/commands/watcher_commands.rs | 125 +++++++++ src-tauri/src/core/http.rs | 4 + src-tauri/src/main.rs | 57 ++++- src-tauri/src/services/config_watcher.rs | 279 +++++++++++++++++++++ src-tauri/src/services/mod.rs | 1 + 7 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/commands/watcher_commands.rs create mode 100644 src-tauri/src/services/config_watcher.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9e87945..7605a3c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod tool_commands; pub mod tool_management; pub mod types; pub mod update_commands; +pub mod watcher_commands; pub mod window_commands; // 重新导出所有命令函数 @@ -22,4 +23,5 @@ pub use stats_commands::*; pub use tool_commands::*; pub use tool_management::*; pub use update_commands::*; +pub use watcher_commands::*; pub use window_commands::*; diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index f43437f..af16019 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -29,6 +29,8 @@ fn create_minimal_config() -> GlobalConfig { hide_session_config_hint: false, log_config: LogConfig::default(), onboarding_status: None, + external_watch_enabled: true, + external_poll_interval_ms: 5000, } } diff --git a/src-tauri/src/commands/watcher_commands.rs b/src-tauri/src/commands/watcher_commands.rs new file mode 100644 index 0000000..1f22f69 --- /dev/null +++ b/src-tauri/src/commands/watcher_commands.rs @@ -0,0 +1,125 @@ +use duckcoding::services::config_watcher::NotifyWatcherManager; +use duckcoding::utils::config::{read_global_config, write_global_config}; +use tauri::AppHandle; +use tracing::{debug, error, warn}; + +use crate::ExternalWatcherState; + +/// 获取当前监听状态 +#[tauri::command] +pub async fn get_watcher_status( + state: tauri::State<'_, ExternalWatcherState>, +) -> Result { + let guard = state + .manager + .lock() + .map_err(|e| format!("锁定 watcher 状态失败: {e}"))?; + let running = guard.is_some(); + debug!(running, "Watcher status queried"); + Ok(running) +} + +/// 按需开启监听 +#[tauri::command] +pub async fn start_watcher_if_needed( + app: AppHandle, + state: tauri::State<'_, ExternalWatcherState>, +) -> Result { + { + let guard = state + .manager + .lock() + .map_err(|e| format!("锁定 watcher 状态失败: {e}"))?; + if guard.is_some() { + debug!("Watcher already running, skip start"); + return Ok(true); + } + } + + // 检查全局配置是否允许 + if let Ok(Some(cfg)) = read_global_config() { + if !cfg.external_watch_enabled { + warn!("Global config disabled external watch, skip start"); + return Err("已在全局配置中关闭监听".to_string()); + } + debug!( + enabled = cfg.external_watch_enabled, + poll_interval_ms = cfg.external_poll_interval_ms, + "Watcher start check: config loaded" + ); + } + + let manager = NotifyWatcherManager::start_all(app.clone()).map_err(|e| { + error!(error = ?e, "Failed to start notify watchers"); + e.to_string() + })?; + let mut guard = state + .manager + .lock() + .map_err(|e| format!("锁定 watcher 状态失败: {e}"))?; + *guard = Some(manager); + debug!("Watcher started and manager stored"); + Ok(true) +} + +/// 停止监听 +#[tauri::command] +pub async fn stop_watcher(state: tauri::State<'_, ExternalWatcherState>) -> Result { + let mut guard = state + .manager + .lock() + .map_err(|e| format!("锁定 watcher 状态失败: {e}"))?; + if guard.is_none() { + warn!("Stop watcher called but watcher not running"); + return Ok(false); + } + *guard = None; + debug!("Watcher stopped and manager cleared"); + Ok(true) +} + +/// 同步保存监听开关并尝试应用(开启时立即启动 watcher;关闭时停止) +#[tauri::command] +pub async fn save_watcher_settings( + app: AppHandle, + state: tauri::State<'_, ExternalWatcherState>, + enabled: bool, + poll_interval_ms: Option, +) -> Result<(), String> { + let mut cfg = read_global_config() + .map_err(|e| format!("读取全局配置失败: {e}"))? + .ok_or_else(|| "全局配置不存在,无法保存监听设置".to_string())?; + let old_enabled = cfg.external_watch_enabled; + let old_interval = cfg.external_poll_interval_ms; + cfg.external_watch_enabled = enabled; + if let Some(interval) = poll_interval_ms { + cfg.external_poll_interval_ms = interval; + } + write_global_config(&cfg).map_err(|e| format!("保存全局配置失败: {e}"))?; + + if enabled { + debug!( + enabled, + poll_interval_ms = cfg.external_poll_interval_ms, + old_enabled, + old_interval, + "Saving watcher settings: starting watcher" + ); + let started = start_watcher_if_needed(app, state).await?; + if !started { + error!("Watcher should start but start_watcher_if_needed returned false"); + return Err("监听未能启动".to_string()); + } + } else { + debug!( + enabled, + poll_interval_ms = cfg.external_poll_interval_ms, + old_enabled, + old_interval, + "Saving watcher settings: stopping watcher" + ); + let _ = stop_watcher(state).await?; + } + + Ok(()) +} diff --git a/src-tauri/src/core/http.rs b/src-tauri/src/core/http.rs index 4999172..6444a00 100644 --- a/src-tauri/src/core/http.rs +++ b/src-tauri/src/core/http.rs @@ -115,6 +115,8 @@ mod tests { hide_session_config_hint: false, log_config: crate::models::config::LogConfig::default(), onboarding_status: None, + external_watch_enabled: true, + external_poll_interval_ms: 5000, }; let url = build_proxy_url(&config).unwrap(); @@ -145,6 +147,8 @@ mod tests { hide_session_config_hint: false, log_config: crate::models::config::LogConfig::default(), onboarding_status: None, + external_watch_enabled: true, + external_poll_interval_ms: 5000, }; let url = build_proxy_url(&config).unwrap(); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 134a3a4..9c49dfc 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,6 +4,7 @@ use duckcoding::utils::config::apply_proxy_if_configured; use serde::Serialize; use std::env; +use std::sync::Mutex; use tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, @@ -15,13 +16,19 @@ mod commands; use commands::*; // 导入透明代理服务 -use duckcoding::{ProxyManager, ToolStatusCache, TransparentProxyService}; +use duckcoding::TransparentProxyService; +use duckcoding::{services::config_watcher::NotifyWatcherManager, services::EXTERNAL_CHANGE_EVENT}; +use duckcoding::{ProxyManager, ToolStatusCache}; use std::sync::Arc; use tokio::sync::Mutex as TokioMutex; const CLOSE_CONFIRM_EVENT: &str = "duckcoding://request-close-action"; const SINGLE_INSTANCE_EVENT: &str = "single-instance"; +struct ExternalWatcherState { + manager: Mutex>, +} + #[derive(Clone, Serialize)] struct SingleInstancePayload { args: Vec, @@ -155,6 +162,9 @@ fn main() { let transparent_proxy_state = TransparentProxyState { service: Arc::new(TokioMutex::new(transparent_proxy_service)), }; + let watcher_state = ExternalWatcherState { + manager: Mutex::new(None), + }; // 创建多工具代理管理器(新架构) let proxy_manager = Arc::new(ProxyManager::new()); @@ -189,6 +199,7 @@ fn main() { let builder = tauri::Builder::default() .manage(transparent_proxy_state) .manage(proxy_manager_state) + .manage(watcher_state) .manage(update_service_state) .manage(tool_status_cache_state) .manage(tool_registry_state) @@ -233,6 +244,39 @@ fn main() { tracing::info!(working_dir = ?env::current_dir(), "当前工作目录"); + // 启动通知式配置 watcher(若可用),增加日志方便排查 + if let Some(state) = app.try_state::() { + let enable_watch = match duckcoding::utils::config::read_global_config() { + Ok(Some(cfg)) => cfg.external_watch_enabled, + _ => true, + }; + if !enable_watch { + tracing::info!("External config watcher disabled by config"); + } + + if let Ok(mut guard) = state.manager.lock() { + if guard.is_none() && enable_watch { + match NotifyWatcherManager::start_all(app.handle().clone()) { + Ok(manager) => { + tracing::debug!( + "Config notify watchers started, emitting event {EXTERNAL_CHANGE_EVENT}" + ); + *guard = Some(manager); + } + Err(err) => { + tracing::error!("Failed to start notify watchers: {err:?}"); + } + } + } else { + tracing::info!( + already_running = guard.is_some(), + enable_watch, + "Skip starting notify watcher" + ); + } + } + } + // 创建系统托盘菜单 let tray_menu = create_tray_menu(app.handle())?; let app_handle2 = app.handle().clone(); @@ -370,6 +414,12 @@ fn main() { save_global_config, get_global_config, generate_api_key_for_tool, + get_migration_report, + list_profile_descriptors, + get_external_changes, + ack_external_change, + clean_legacy_backups, + import_native_change, get_usage_stats, get_user_quota, fetch_api, @@ -402,6 +452,11 @@ fn main() { clear_all_sessions, update_session_config, update_session_note, + // 配置监听控制 + get_watcher_status, + start_watcher_if_needed, + stop_watcher, + save_watcher_settings, // 更新管理相关命令 check_for_app_updates, download_app_update, diff --git a/src-tauri/src/services/config_watcher.rs b/src-tauri/src/services/config_watcher.rs new file mode 100644 index 0000000..73bcb2f --- /dev/null +++ b/src-tauri/src/services/config_watcher.rs @@ -0,0 +1,279 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use notify::{ + Config as NotifyConfig, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, +}; +use serde::Serialize; +use tauri::Emitter; +use tracing::{debug, warn}; + +use crate::services::config::ConfigService; +use crate::services::profile_store::file_checksum; +use crate::Tool; + +#[derive(Debug, Clone, Serialize)] +pub struct ExternalChange { + pub tool_id: String, + pub path: PathBuf, + pub checksum: Option, + pub timestamp: DateTime, + pub dirty: bool, + pub fallback_poll: bool, +} + +/// Tauri 事件名称(外部配置变更) +pub const EXTERNAL_CHANGE_EVENT: &str = "external-config-changed"; + +/// 简单的轮询 watcher,便于测试与跨平台复用;后续可替换为 OS 级通知。 +pub struct ConfigWatcher { + stop: Arc, + handle: Option>, +} + +impl ConfigWatcher { + /// 轮询监听单文件变更。 + pub fn watch_file_polling( + tool_id: impl Into, + path: PathBuf, + poll_interval: Duration, + mark_dirty: bool, + ) -> Result<(Self, mpsc::Receiver)> { + let tool_id = tool_id.into(); + let mut last_checksum = file_checksum(&path).ok(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_token = stop.clone(); + let (tx, rx) = mpsc::channel(); + let watch_path = path.clone(); + + let handle = thread::spawn(move || { + while !stop_token.load(Ordering::Relaxed) { + let checksum = file_checksum(&watch_path).ok(); + if checksum.is_some() && checksum != last_checksum { + // 轻微防抖,避免写入过程中的空文件/瞬时内容导致重复事件 + thread::sleep(Duration::from_millis(10)); + let stable_checksum = file_checksum(&watch_path).ok().or(checksum.clone()); + + if stable_checksum.is_some() && stable_checksum != last_checksum { + last_checksum = stable_checksum.clone(); + let change = ExternalChange { + tool_id: tool_id.clone(), + path: watch_path.clone(), + checksum: stable_checksum, + timestamp: Utc::now(), + dirty: mark_dirty, + fallback_poll: true, + }; + let _ = tx.send(change); + } + } + thread::sleep(poll_interval); + } + }); + + Ok(( + Self { + stop, + handle: Some(handle), + }, + rx, + )) + } +} + +/// 基于 notify 的实时 watcher,收到事件后写入 dirty 状态并广播到前端。 +pub struct NotifyWatcherManager { + _watchers: Vec, +} + +impl NotifyWatcherManager { + fn watch_single( + tool: Tool, + path: PathBuf, + app: tauri::AppHandle, + ) -> Result { + let path_for_cb = path.clone(); + let tool_for_state = tool.clone(); + let mut last_checksum = ConfigService::compute_native_checksum(&tool_for_state); + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + if let Ok(event) = res { + match event.kind { + EventKind::Modify(_) | EventKind::Create(_) => { + let checksum = ConfigService::compute_native_checksum(&tool_for_state); + // 去重:相同 checksum 不重复触发 + if checksum == last_checksum { + return; + } + last_checksum = checksum.clone(); + + match ConfigService::mark_external_change( + &tool_for_state, + path_for_cb.clone(), + checksum, + ) { + Ok(change) => { + // 仅在确实变脏时通知前端,避免内部写入误报 + if change.dirty { + debug!( + tool = %change.tool_id, + path = %change.path, + checksum = ?change.checksum, + "检测到配置文件改动(notify watcher)" + ); + let _ = app.emit(EXTERNAL_CHANGE_EVENT, change); + } + } + Err(err) => { + warn!( + tool = %tool_for_state.id, + path = ?path_for_cb, + error = ?err, + "标记外部变更失败" + ); + } + } + } + _ => {} + } + } + }, + NotifyConfig::default(), + )?; + + watcher.watch(&path, RecursiveMode::NonRecursive)?; + Ok(watcher) + } + + /// 为已存在的配置文件启动 watcher,方便 UI 实时感知。 + pub fn start_all(app: tauri::AppHandle) -> Result { + let mut watchers = Vec::new(); + for tool in Tool::all() { + let mut seen = HashSet::new(); + for path in ConfigService::config_paths(&tool) { + if !seen.insert(path.clone()) { + continue; + } + if !path.exists() { + warn!( + tool = %tool.id, + path = ?path, + "配置文件不存在,跳过通知 watcher(将依赖轮询/手动刷新)" + ); + continue; + } + let watcher = Self::watch_single(tool.clone(), path, app.clone())?; + watchers.push(watcher); + } + } + debug!(count = watchers.len(), "通知 watcher 启动完成"); + Ok(Self { + _watchers: watchers, + }) + } +} + +impl Drop for ConfigWatcher { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::SystemTime; + + #[test] + fn watcher_emits_on_change_and_filters_duplicate_checksum() -> Result<()> { + let dir = std::env::temp_dir().join(format!( + "duckcoding_watch_test_{}", + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + )); + if dir.exists() { + fs::remove_dir_all(&dir)?; + } + fs::create_dir_all(&dir)?; + let path = dir.join("settings.json"); + fs::write(&path, r#"{"env":{"KEY":"A"}}"#)?; + + let (_watcher, rx) = ConfigWatcher::watch_file_polling( + "claude-code", + path.clone(), + Duration::from_millis(50), + true, + )?; + + // 改变内容,期望收到事件 + fs::write(&path, r#"{"env":{"KEY":"B"}}"#)?; + let change = rx + .recv_timeout(Duration::from_secs(3)) + .expect("should receive change event"); + assert_eq!(change.tool_id, "claude-code"); + assert_eq!(change.path, path); + assert!(change.checksum.is_some()); + + // 再写入相同内容,不应再次触发 + fs::write(&path, r#"{"env":{"KEY":"B"}}"#)?; + assert!(rx.recv_timeout(Duration::from_millis(300)).is_err()); + + let _ = fs::remove_dir_all(&dir); + Ok(()) + } + + #[test] + fn watcher_respects_mark_dirty_flag() -> Result<()> { + let dir = std::env::temp_dir().join(format!( + "duckcoding_watch_test_mark_dirty_{}", + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + )); + if dir.exists() { + fs::remove_dir_all(&dir)?; + } + fs::create_dir_all(&dir)?; + let path = dir.join("settings.json"); + fs::write(&path, r#"{"env":{"KEY":"X"}}"#)?; + + // mark_dirty = false,应当仍能收到事件,但 dirty 为 false + let (_watcher, rx) = ConfigWatcher::watch_file_polling( + "codex", + path.clone(), + Duration::from_millis(30), + false, + )?; + + fs::write(&path, r#"{"env":{"KEY":"Y"}}"#)?; + let change = rx + .recv_timeout(Duration::from_secs(3)) + .expect("should receive change event"); + + assert_eq!(change.tool_id, "codex"); + assert_eq!(change.path, path); + assert!(change.checksum.is_some()); + assert!(!change.dirty, "dirty flag should respect mark_dirty=false"); + assert!( + change.fallback_poll, + "polling watcher should mark fallback_poll" + ); + + let _ = fs::remove_dir_all(&dir); + Ok(()) + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 9f47862..146713a 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -8,6 +8,7 @@ // - session: 会话管理(透明代理请求追踪) pub mod config; +pub mod config_watcher; pub mod migration; pub mod profile_store; pub mod proxy; From b8710ee84cae6053678f65f293d13931e00753f4 Mon Sep 17 00:00:00 2001 From: Wangnov Date: Tue, 2 Dec 2025 13:47:44 +0800 Subject: [PATCH 06/10] =?UTF-8?q?feat(proxy):=20=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/proxy/proxy_service.rs | 6 +++ .../proxy/transparent_proxy_config.rs | 42 ++++--------------- src-tauri/src/services/session/manager.rs | 14 +++++-- 3 files changed, 24 insertions(+), 38 deletions(-) diff --git a/src-tauri/src/services/proxy/proxy_service.rs b/src-tauri/src/services/proxy/proxy_service.rs index 0faaf87..753a8e1 100644 --- a/src-tauri/src/services/proxy/proxy_service.rs +++ b/src-tauri/src/services/proxy/proxy_service.rs @@ -231,6 +231,8 @@ mod tests { hide_session_config_hint: false, log_config: crate::models::config::LogConfig::default(), onboarding_status: None, + external_watch_enabled: true, + external_poll_interval_ms: 5000, }; let url = ProxyService::build_proxy_url(&config); @@ -261,6 +263,8 @@ mod tests { hide_session_config_hint: false, log_config: crate::models::config::LogConfig::default(), onboarding_status: None, + external_watch_enabled: true, + external_poll_interval_ms: 5000, }; let url = ProxyService::build_proxy_url(&config); @@ -294,6 +298,8 @@ mod tests { hide_session_config_hint: false, log_config: crate::models::config::LogConfig::default(), onboarding_status: None, + external_watch_enabled: true, + external_poll_interval_ms: 5000, }; let url = ProxyService::build_proxy_url(&config); diff --git a/src-tauri/src/services/proxy/transparent_proxy_config.rs b/src-tauri/src/services/proxy/transparent_proxy_config.rs index 184a234..1e7c6c6 100644 --- a/src-tauri/src/services/proxy/transparent_proxy_config.rs +++ b/src-tauri/src/services/proxy/transparent_proxy_config.rs @@ -1,5 +1,6 @@ // 透明代理配置管理服务 use crate::models::{GlobalConfig, Tool, ToolProxyConfig}; +use crate::services::profile_store::{load_profile_payload, ProfilePayload}; use anyhow::{Context, Result}; use serde_json::{Map, Value}; use std::collections::HashMap; @@ -553,40 +554,13 @@ impl TransparentProxyConfigService { anyhow::bail!("从备份读取配置目前仅支持 Claude Code"); } - let backup_path = tool.backup_path(profile_name); - - if !backup_path.exists() { - anyhow::bail!("备份配置文件不存在: {backup_path:?}"); + let payload = + load_profile_payload(&tool.id, profile_name).context("读取集中存储的配置失败")?; + match payload { + ProfilePayload::Claude { + api_key, base_url, .. + } => Ok((api_key, base_url)), + _ => anyhow::bail!("配置内容与工具不匹配: {}", tool.id), } - - let content = fs::read_to_string(&backup_path).context("读取备份配置失败")?; - let backup_data: Value = serde_json::from_str(&content).context("解析备份配置失败")?; - - // 兼容新旧格式 - let api_key = backup_data - .get("ANTHROPIC_AUTH_TOKEN") - .and_then(|v| v.as_str()) - .or_else(|| { - backup_data - .get("env") - .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) - .and_then(|v| v.as_str()) - }) - .ok_or_else(|| anyhow::anyhow!("备份配置中未找到 API Key"))? - .to_string(); - - let base_url = backup_data - .get("ANTHROPIC_BASE_URL") - .and_then(|v| v.as_str()) - .or_else(|| { - backup_data - .get("env") - .and_then(|env| env.get("ANTHROPIC_BASE_URL")) - .and_then(|v| v.as_str()) - }) - .ok_or_else(|| anyhow::anyhow!("备份配置中未找到 Base URL"))? - .to_string(); - - Ok((api_key, base_url)) } } diff --git a/src-tauri/src/services/session/manager.rs b/src-tauri/src/services/session/manager.rs index bf7cfb3..d4ed837 100644 --- a/src-tauri/src/services/session/manager.rs +++ b/src-tauri/src/services/session/manager.rs @@ -42,10 +42,9 @@ impl SessionManager { /// 获取数据库路径 fn get_db_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found") - })?; - Ok(home.join(".duckcoding").join("sessions.db")) + let base = crate::utils::config::config_dir() + .map_err(|e| std::io::Error::other(format!("Failed to resolve config dir: {e}")))?; + Ok(base.join("sessions.db")) } /// 启动后台任务 @@ -173,9 +172,16 @@ impl SessionManager { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; + use std::env; + use tempfile::TempDir; #[tokio::test] + #[serial] async fn test_session_manager_send_event() { + let temp = TempDir::new().expect("create temp dir"); + env::set_var("DUCKCODING_CONFIG_DIR", temp.path()); + let timestamp = chrono::Utc::now().timestamp(); let event = SessionEvent::NewRequest { session_id: "test_user_session_abc-123".to_string(), From 5caa08e49ad1077b037c512579e9bda72770d967 Mon Sep 17 00:00:00 2001 From: Wangnov Date: Tue, 2 Dec 2025 13:48:24 +0800 Subject: [PATCH 07/10] =?UTF-8?q?feat(frontend):=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=B0=81=E8=A3=85=E4=B8=8E=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/scroll-area.tsx | 44 ++++++ src/hooks/useProfileLoader.ts | 7 +- src/lib/tauri-commands.ts | 137 +++++++++++++++++- .../components/ApiConfigForm.tsx | 27 +--- .../hooks/useConfigManagement.ts | 7 +- src/pages/ConfigurationPage/index.tsx | 132 +++++++++-------- 6 files changed, 257 insertions(+), 97 deletions(-) create mode 100644 src/components/ui/scroll-area.tsx diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..b8f7265 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; + +import { cn } from '@/lib/utils'; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/src/hooks/useProfileLoader.ts b/src/hooks/useProfileLoader.ts index 5bf065d..5c766e5 100644 --- a/src/hooks/useProfileLoader.ts +++ b/src/hooks/useProfileLoader.ts @@ -25,16 +25,15 @@ export function useProfileLoader( const [activeConfigs, setActiveConfigs] = useState>({}); /** - * 并行加载所有已安装工具的配置 + * 并行加载所有工具的配置(即便未检测到二进制也尝试读取配置目录) */ const loadAllProfiles = useCallback(async () => { - const installedTools = tools.filter((t) => t.installed); const profileData: Record = {}; const configData: Record = {}; // 并行加载所有工具的配置,提升性能 const results = await Promise.allSettled( - installedTools.flatMap((tool) => [ + tools.flatMap((tool) => [ listProfiles(tool.id).then((profiles) => ({ tool, type: 'profiles' as const, @@ -63,7 +62,7 @@ export function useProfileLoader( }); // 确保所有工具都有数据(即使加载失败) - installedTools.forEach((tool) => { + tools.forEach((tool) => { if (!profileData[tool.id]) { profileData[tool.id] = []; } diff --git a/src/lib/tauri-commands.ts b/src/lib/tauri-commands.ts index 51ff0e9..8190698 100644 --- a/src/lib/tauri-commands.ts +++ b/src/lib/tauri-commands.ts @@ -63,6 +63,9 @@ export interface GlobalConfig { hide_session_config_hint?: boolean; // 日志系统配置 log_config?: LogConfig; + // 配置监听 + external_watch_enabled?: boolean; + external_poll_interval_ms?: number; } export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; @@ -174,6 +177,52 @@ export interface GeminiSettingsPayload { env: GeminiEnvConfig; } +export interface ProfileDescriptor { + tool_id: string; + name: string; + format: string; + path: string; + updated_at?: string; + created_at?: string; + source?: string; + checksum?: string; + tags?: string[]; +} + +export interface ExternalConfigChange { + tool_id: string; + path: string; + checksum?: string; + detected_at: string; + dirty: boolean; + timestamp?: string; + fallback_poll?: boolean; +} + +export interface MigrationRecord { + tool_id: string; + profile_name: string; + from_path: string; + to_path: string; + succeeded: boolean; + message?: string | null; + timestamp: string; +} + +export interface LegacyCleanupResult { + tool_id: string; + removed: string[]; + failed: [string, string][]; +} + +export interface ImportExternalChangeResult { + profileName: string; + wasNew: boolean; + replaced: boolean; + beforeChecksum?: string | null; + checksum?: string | null; +} + export async function checkInstallations(): Promise { return await invoke('check_installations'); } @@ -230,6 +279,38 @@ export async function listProfiles(tool: string): Promise { return await invoke('list_profiles', { tool }); } +export async function listProfileDescriptors(tool?: string): Promise { + return await invoke('list_profile_descriptors', { tool }); +} + +export async function getExternalChanges(): Promise { + return await invoke('get_external_changes'); +} + +export async function ackExternalChange(tool: string): Promise { + return await invoke('ack_external_change', { tool }); +} + +export async function getMigrationReport(): Promise { + return await invoke('get_migration_report'); +} + +export async function cleanLegacyBackups(): Promise { + return await invoke('clean_legacy_backups'); +} + +export async function importNativeChange( + tool: string, + profile: string, + asNew: boolean, +): Promise { + return await invoke('import_native_change', { + tool, + profile, + asNew, + }); +} + export async function switchProfile(tool: string, profile: string): Promise { return await invoke('switch_profile', { tool, profile }); } @@ -263,6 +344,29 @@ export async function getCurrentProxy(): Promise { return await invoke('get_current_proxy'); } +// 配置监听控制 +export async function getWatcherStatus(): Promise { + return await invoke('get_watcher_status'); +} + +export async function startWatcherIfNeeded(): Promise { + return await invoke('start_watcher_if_needed'); +} + +export async function stopWatcher(): Promise { + return await invoke('stop_watcher'); +} + +export async function saveWatcherSettings( + enabled: boolean, + pollIntervalMs?: number, +): Promise { + await invoke('save_watcher_settings', { + enabled, + pollIntervalMs, + }); +} + export async function applyProxyNow(): Promise { return await invoke('apply_proxy_now'); } @@ -320,18 +424,41 @@ export async function applyCloseAction(action: CloseAction): Promise { return await invoke('handle_close_action', { action }); } -export async function getClaudeSettings(): Promise { +export interface ClaudeSettingsPayload { + settings: JsonObject; + extraConfig?: JsonObject | null; +} + +export async function getClaudeSettings(): Promise { const data = await invoke('get_claude_settings'); if (data && typeof data === 'object' && !Array.isArray(data)) { - return data as JsonObject; + const payload = data as Record; + const settings = + payload.settings && typeof payload.settings === 'object' && !Array.isArray(payload.settings) + ? (payload.settings as JsonObject) + : {}; + const extraConfig = + payload.extraConfig && + typeof payload.extraConfig === 'object' && + !Array.isArray(payload.extraConfig) + ? (payload.extraConfig as JsonObject) + : null; + return { settings, extraConfig }; } - return {}; + return { settings: {}, extraConfig: null }; } -export async function saveClaudeSettings(settings: JsonObject): Promise { - return await invoke('save_claude_settings', { settings }); +export async function saveClaudeSettings( + settings: JsonObject, + extraConfig?: JsonObject | null, +): Promise { + const payload: Record = { settings }; + if (extraConfig !== undefined) { + payload.extraConfig = extraConfig; + } + return await invoke('save_claude_settings', payload); } export async function getClaudeSchema(): Promise { diff --git a/src/pages/ConfigurationPage/components/ApiConfigForm.tsx b/src/pages/ConfigurationPage/components/ApiConfigForm.tsx index 77b02b7..45702fe 100644 --- a/src/pages/ConfigurationPage/components/ApiConfigForm.tsx +++ b/src/pages/ConfigurationPage/components/ApiConfigForm.tsx @@ -17,12 +17,9 @@ import { SelectValue, } from '@/components/ui/select'; import { Loader2, Save, Sparkles } from 'lucide-react'; -import { logoMap } from '@/utils/constants'; -import type { ToolStatus } from '@/lib/tauri-commands'; interface ApiConfigFormProps { selectedTool: string; - setSelectedTool: (tool: string) => void; provider: string; setProvider: (provider: string) => void; apiKey: string; @@ -31,7 +28,6 @@ interface ApiConfigFormProps { setBaseUrl: (url: string) => void; profileName: string; setProfileName: (name: string) => void; - installedTools: ToolStatus[]; configuring: boolean; generatingKey: boolean; onGenerateKey: () => void; @@ -41,7 +37,6 @@ interface ApiConfigFormProps { export function ApiConfigForm({ selectedTool, - setSelectedTool, provider, setProvider, apiKey, @@ -50,7 +45,6 @@ export function ApiConfigForm({ setBaseUrl, profileName, setProfileName, - installedTools, configuring, generatingKey, onGenerateKey, @@ -61,29 +55,10 @@ export function ApiConfigForm({ API 配置 - 为已安装的工具配置 API 密钥 + 为当前工具配置 API 密钥
-
- - -
-
{ + const next = [...extraEntries]; + next[idx] = { ...entry, key: e.target.value }; + setExtraEntries(next); + setExtraDirty(true); + }} + placeholder="primaryApiKey" + /> +
+
+ + { + const next = [...extraEntries]; + next[idx] = { ...entry, value: e.target.value }; + setExtraEntries(next); + setExtraDirty(true); + }} + placeholder='如 "sk-..." 或 {"enabled":true}' + /> +
+
+ + + ))} + + + {extraError ? ( +

格式错误:{extraError}

+ ) : extraDirty ? ( +

config.json 已修改,保存后生效。

+ ) : ( +

同步保存时将一并写入 config.json。

+ )} +
+ +
+
+
+ ); } From 9fb6aac7d417c2d73f31f1f4c327f6e111717a86 Mon Sep 17 00:00:00 2001 From: Wangnov Date: Tue, 2 Dec 2025 13:50:05 +0800 Subject: [PATCH 09/10] =?UTF-8?q?chore(logger):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/core/logger.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/core/logger.rs b/src-tauri/src/core/logger.rs index c8de9ba..d0f373a 100644 --- a/src-tauri/src/core/logger.rs +++ b/src-tauri/src/core/logger.rs @@ -212,10 +212,13 @@ fn get_log_dir(file_path: Option<&str>) -> anyhow::Result { /// 仅限调整日志级别,格式和输出目标的变更仍需要重启应用。 /// /// # 示例 -/// ``` -/// use duckcoding::models::config::LogLevel; -/// use duckcoding::core::update_log_level; +/// ```no_run +/// use duckcoding::core::{init_logger, update_log_level}; +/// use duckcoding::models::config::{LogConfig, LogLevel}; /// +/// // 先初始化日志系统 +/// init_logger(&LogConfig::default()).expect("初始化日志系统失败"); +/// // 再热更新日志级别 /// update_log_level(LogLevel::Debug).expect("更新日志级别失败"); /// ``` pub fn update_log_level(new_level: LogLevel) -> anyhow::Result<()> { From 34836129209004c0928c3c423e347398447c521a Mon Sep 17 00:00:00 2001 From: Wangnov Date: Tue, 2 Dec 2025 13:50:28 +0800 Subject: [PATCH 10/10] =?UTF-8?q?feat(config-ui):=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=85=A5=E5=8F=A3=E4=B8=8E=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E6=94=B9=E5=8A=A8=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 5 +- src/hooks/useAppEvents.ts | 11 +- .../hooks/useProfileManagement.ts | 226 ++++++++- src/pages/ProfileSwitchPage/index.tsx | 178 +++++-- .../components/ConfigManagementTab.tsx | 439 ++++++++++++++++++ src/pages/SettingsPage/index.tsx | 12 + 6 files changed, 806 insertions(+), 65 deletions(-) create mode 100644 src/pages/SettingsPage/components/ConfigManagementTab.tsx diff --git a/src/App.tsx b/src/App.tsx index 3069304..db2d0dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -345,7 +345,10 @@ function App() { console.log('Navigate to config:', detail); }, onNavigateToInstall: () => setActiveTab('install'), - onNavigateToSettings: () => setActiveTab('settings'), + onNavigateToSettings: (detail) => { + setSettingsInitialTab(detail?.tab ?? 'basic'); + setActiveTab('settings'); + }, onNavigateToTransparentProxy: (detail) => { setActiveTab('transparent-proxy'); if (detail?.toolId) { diff --git a/src/hooks/useAppEvents.ts b/src/hooks/useAppEvents.ts index 9985486..7f446da 100644 --- a/src/hooks/useAppEvents.ts +++ b/src/hooks/useAppEvents.ts @@ -29,7 +29,7 @@ interface AppEventsOptions { onSingleInstance: (message: string) => void; onNavigateToConfig: (detail?: { toolId?: string }) => void; onNavigateToInstall: () => void; - onNavigateToSettings: () => void; + onNavigateToSettings: (detail?: { tab?: string }) => void; onNavigateToTransparentProxy: (detail?: { toolId?: string }) => void; onRefreshTools: () => void; executeCloseAction: (action: CloseAction, remember: boolean, autoTriggered: boolean) => void; @@ -140,16 +140,21 @@ export function useAppEvents(options: AppEventsOptions) { onNavigateToTransparentProxy(customEvent.detail); }; + const handleNavigateToSettings = (event: Event) => { + const customEvent = event as CustomEvent<{ tab?: string }>; + onNavigateToSettings(customEvent.detail); + }; + window.addEventListener('navigate-to-config', handleNavigateToConfig); window.addEventListener('navigate-to-install', onNavigateToInstall); - window.addEventListener('navigate-to-settings', onNavigateToSettings); + window.addEventListener('navigate-to-settings', handleNavigateToSettings); window.addEventListener('navigate-to-transparent-proxy', handleNavigateToTransparentProxy); window.addEventListener('refresh-tools', onRefreshTools); return () => { window.removeEventListener('navigate-to-config', handleNavigateToConfig); window.removeEventListener('navigate-to-install', onNavigateToInstall); - window.removeEventListener('navigate-to-settings', onNavigateToSettings); + window.removeEventListener('navigate-to-settings', handleNavigateToSettings); window.removeEventListener('navigate-to-transparent-proxy', handleNavigateToTransparentProxy); window.removeEventListener('refresh-tools', onRefreshTools); }; diff --git a/src/pages/ProfileSwitchPage/hooks/useProfileManagement.ts b/src/pages/ProfileSwitchPage/hooks/useProfileManagement.ts index 3abb19a..0b1d217 100644 --- a/src/pages/ProfileSwitchPage/hooks/useProfileManagement.ts +++ b/src/pages/ProfileSwitchPage/hooks/useProfileManagement.ts @@ -1,4 +1,5 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; +import { listen } from '@tauri-apps/api/event'; import { switchProfile, deleteProfile, @@ -7,9 +8,21 @@ import { startToolProxy, stopToolProxy, getAllProxyStatus, + saveGlobalConfig, + getExternalChanges, + ackExternalChange, + listProfileDescriptors, + getMigrationReport, + importNativeChange, + cleanLegacyBackups, type ToolStatus, type GlobalConfig, type AllProxyStatus, + type ExternalConfigChange, + type ProfileDescriptor, + type MigrationRecord, + type ImportExternalChangeResult, + type LegacyCleanupResult, } from '@/lib/tauri-commands'; import { useProfileLoader } from '@/hooks/useProfileLoader'; @@ -19,10 +32,18 @@ export function useProfileManagement( ) { const [switching, setSwitching] = useState(false); const [deletingProfiles, setDeletingProfiles] = useState>({}); - const [selectedProfile, setSelectedProfile] = useState>({}); const [globalConfig, setGlobalConfig] = useState(null); const [allProxyStatus, setAllProxyStatus] = useState({}); const [loadingTools, setLoadingTools] = useState>(new Set()); + const [externalChanges, setExternalChanges] = useState([]); + const [profileDescriptors, setProfileDescriptors] = useState([]); + const [migrationRecords, setMigrationRecords] = useState([]); + const [loadingInsights, setLoadingInsights] = useState(false); + const [cleaningLegacy, setCleaningLegacy] = useState(false); + const [cleanupResults, setCleanupResults] = useState([]); + const [notifyEnabled, setNotifyEnabled] = useState(true); + const [listenerError, setListenerError] = useState(null); + const [pollIntervalMs, setPollIntervalMs] = useState(5000); // 使用共享配置加载 Hook,传入排序转换函数 const { profiles, setProfiles, activeConfigs, setActiveConfigs, loadAllProfiles } = @@ -33,6 +54,12 @@ export function useProfileManagement( try { const config = await getGlobalConfig(); setGlobalConfig(config); + if (config?.external_watch_enabled !== undefined) { + setNotifyEnabled(config.external_watch_enabled); + } + if (config?.external_poll_interval_ms !== undefined) { + setPollIntervalMs(config.external_poll_interval_ms); + } } catch (error) { console.error('Failed to load global config:', error); } @@ -48,6 +75,175 @@ export function useProfileManagement( } }, []); + const loadExternalChanges = useCallback(async () => { + setLoadingInsights(true); + try { + const [changes, descriptors, records] = await Promise.all([ + getExternalChanges().catch((error) => { + console.error('Failed to load external changes:', error); + return []; + }), + listProfileDescriptors().catch((error) => { + console.error('Failed to load profile descriptors:', error); + return []; + }), + getMigrationReport().catch((error) => { + console.error('Failed to load migration report:', error); + return []; + }), + ]); + setExternalChanges(changes); + setProfileDescriptors(descriptors); + setMigrationRecords(records); + } finally { + setLoadingInsights(false); + } + }, []); + + const acknowledgeChange = useCallback( + async (toolId: string) => { + try { + await ackExternalChange(toolId); + await loadExternalChanges(); + } catch (error) { + console.error('Failed to acknowledge external change:', error); + } + }, + [loadExternalChanges], + ); + + // 监听后端事件,实时追加外部改动 + useEffect(() => { + let unlisten: (() => void) | undefined; + const setup = async () => { + try { + unlisten = await listen('external-config-changed', (event) => { + if (!notifyEnabled) return; + const payload = event.payload; + setExternalChanges((prev) => { + const filtered = prev.filter( + (c) => !(c.tool_id === payload.tool_id && c.path === payload.path), + ); + return [...filtered, payload]; + }); + }); + } catch (error) { + console.error('Failed to listen external-config-changed:', error); + setListenerError(String(error)); + } + }; + + void setup(); + return () => { + if (unlisten) { + unlisten(); + } + }; + }, [notifyEnabled]); + + // 轮询补偿:按配置间隔刷新外部改动 + useEffect(() => { + if (!notifyEnabled || pollIntervalMs <= 0) return; + const timer = setInterval(() => { + void loadExternalChanges(); + }, pollIntervalMs); + return () => clearInterval(timer); + }, [notifyEnabled, pollIntervalMs, loadExternalChanges]); + + // 初次加载/重新开启监听时,立即拉取一次外部改动 + useEffect(() => { + if (!notifyEnabled) return; + void loadExternalChanges(); + }, [notifyEnabled, loadExternalChanges]); + + const importExternalChange = useCallback( + async ( + toolId: string, + profileName: string, + asNew: boolean, + ): Promise<{ + success: boolean; + message: string; + result?: ImportExternalChangeResult; + }> => { + const trimmedName = profileName.trim(); + if (!trimmedName) { + return { success: false, message: 'Profile 名称不能为空' }; + } + + setLoadingInsights(true); + try { + const result = await importNativeChange(toolId, trimmedName, asNew); + await Promise.all([loadExternalChanges(), loadAllProfiles()]); + + try { + const active = await getActiveConfig(toolId); + setActiveConfigs((prev) => ({ ...prev, [toolId]: active })); + } catch (error) { + console.error('Failed to refresh active config after import:', error); + } + + return { + success: true, + message: asNew + ? `已将外部改动导入为 ${result.profileName}` + : `已覆盖 profile:${result.profileName}`, + result, + }; + } catch (error) { + console.error('Failed to import external change:', error); + return { success: false, message: String(error) }; + } finally { + setLoadingInsights(false); + } + }, + [loadExternalChanges, loadAllProfiles, setActiveConfigs], + ); + + const cleanupLegacyBackups = useCallback(async (): Promise<{ + success: boolean; + message: string; + results?: LegacyCleanupResult[]; + }> => { + setCleaningLegacy(true); + try { + const results = await cleanLegacyBackups(); + await loadExternalChanges(); + setCleanupResults(results); + const removed = results.reduce((sum, r) => sum + r.removed.length, 0); + const failed = results.reduce((sum, r) => sum + r.failed.length, 0); + return { + success: true, + message: + failed > 0 + ? `已清理旧版备份:成功 ${removed},失败 ${failed}` + : `已清理旧版备份:成功 ${removed}`, + results, + }; + } catch (error) { + console.error('Failed to clean legacy backups:', error); + return { success: false, message: String(error) }; + } finally { + setCleaningLegacy(false); + } + }, [loadExternalChanges]); + + const persistWatchSettings = useCallback( + async (enabled: boolean, intervalMs: number) => { + if (!globalConfig) return; + const next: GlobalConfig = { + ...globalConfig, + external_watch_enabled: enabled, + external_poll_interval_ms: intervalMs, + }; + await saveGlobalConfig(next); + setGlobalConfig(next); + setNotifyEnabled(enabled); + setPollIntervalMs(intervalMs); + }, + [globalConfig], + ); + // 获取指定工具的代理是否启用 const isToolProxyEnabled = useCallback( (toolId: string): boolean => { @@ -90,7 +286,6 @@ export function useProfileManagement( // 切换配置(后端会自动处理透明代理更新) await switchProfile(toolId, profile); - setSelectedProfile((prev) => ({ ...prev, [toolId]: profile })); // 重新加载当前生效的配置 try { @@ -155,15 +350,6 @@ export function useProfileManagement( [toolId]: updatedProfiles, })); - // 清理相关状态 - setSelectedProfile((prev) => { - const updated = { ...prev }; - if (updated[toolId] === profile) { - delete updated[toolId]; - } - return updated; - }); - // 尝试重新加载所有配置,确保与后端同步 try { const latest = await loadAllProfiles(); @@ -287,16 +473,30 @@ export function useProfileManagement( // State switching, deletingProfiles, - selectedProfile, profiles, setProfiles, activeConfigs, globalConfig, allProxyStatus, + externalChanges, + profileDescriptors, + migrationRecords, + loadingInsights, + cleaningLegacy, + cleanupResults, + notifyEnabled, + listenerError, + pollIntervalMs, // Actions loadGlobalConfig, loadAllProxyStatus, + loadExternalChanges, + acknowledgeChange, + importExternalChange, + cleanupLegacyBackups, + persistWatchSettings, + setPollIntervalMs, loadAllProfiles, handleSwitchProfile, handleDeleteProfile, diff --git a/src/pages/ProfileSwitchPage/index.tsx b/src/pages/ProfileSwitchPage/index.tsx index fe4fafb..8a84a1a 100644 --- a/src/pages/ProfileSwitchPage/index.tsx +++ b/src/pages/ProfileSwitchPage/index.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { PageContainer } from '@/components/layout/PageContainer'; import { DeleteConfirmDialog } from '@/components/dialogs/DeleteConfirmDialog'; @@ -11,6 +12,14 @@ import { RestartWarningBanner } from './components/RestartWarningBanner'; import { EmptyToolsState } from './components/EmptyToolsState'; import { useProfileSorting } from './hooks/useProfileSorting'; import { useProfileManagement } from './hooks/useProfileManagement'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { ClaudeConfigManager, CodexConfigManager, @@ -40,6 +49,8 @@ export function ProfileSwitchPage({ }>({ open: false, toolId: '', profile: '' }); const [hideProxyTip, setHideProxyTip] = useState(false); // 临时关闭推荐提示 const [neverShowProxyTip, setNeverShowProxyTip] = useState(false); // 永久隐藏推荐提示 + const [externalDialogOpen, setExternalDialogOpen] = useState(false); + const seenExternalChangesRef = useRef>(new Set()); // 使用拖拽排序Hook const { sensors, applySavedOrder, createDragEndHandler } = useProfileSorting(); @@ -57,6 +68,8 @@ export function ProfileSwitchPage({ loadAllProfiles, handleSwitchProfile, handleDeleteProfile, + externalChanges, + notifyEnabled, isToolProxyEnabled, isToolProxyRunning, } = useProfileManagement(tools, applySavedOrder); @@ -82,12 +95,11 @@ export function ProfileSwitchPage({ // 当工具加载完成后,加载配置 useEffect(() => { - const installedTools = tools.filter((t) => t.installed); - if (installedTools.length > 0) { + if (tools.length > 0) { loadAllProfiles(); - // 设置默认选中的Tab(第一个已安装的工具) + // 设置默认选中的Tab(第一个工具) if (!selectedSwitchTab) { - setSelectedSwitchTab(installedTools[0].id); + setSelectedSwitchTab(tools[0].id); } } // 移除 loadAllProfiles 和 selectedSwitchTab 依赖,避免循环依赖 @@ -176,18 +188,53 @@ export function ProfileSwitchPage({ window.dispatchEvent(new CustomEvent('navigate-to-install')); }; - const installedTools = tools.filter((t) => t.installed); + // 跳转到设置页的配置管理 tab + const navigateToConfigManagement = () => { + window.dispatchEvent( + new CustomEvent('navigate-to-settings', { detail: { tab: 'config-management' } }), + ); + setExternalDialogOpen(false); + }; // 获取当前选中工具的代理状态 const currentToolProxyEnabled = isToolProxyEnabled(selectedSwitchTab); const currentToolProxyRunning = isToolProxyRunning(selectedSwitchTab); - // 获取当前选中工具的名称 const getCurrentToolName = () => { - const tool = installedTools.find((t) => t.id === selectedSwitchTab); + const tool = tools.find((t) => t.id === selectedSwitchTab); return tool?.name || selectedSwitchTab; }; + const getToolDisplayName = (toolId: string) => { + const tool = tools.find((t) => t.id === toolId); + return tool?.name || toolId; + }; + + // 检测到新的外部改动时弹出提醒对话框(去重) + useEffect(() => { + if (!notifyEnabled) { + setExternalDialogOpen(false); + return; + } + if (externalChanges.length === 0) { + seenExternalChangesRef.current = new Set(); + setExternalDialogOpen(false); + return; + } + let hasNew = false; + const seen = seenExternalChangesRef.current; + for (const change of externalChanges) { + const key = `${change.tool_id}|${change.path}`; + if (!seen.has(key)) { + seen.add(key); + hasNew = true; + } + } + if (hasNew) { + setExternalDialogOpen(true); + } + }, [externalChanges, notifyEnabled]); + return (
@@ -200,9 +247,46 @@ export function ProfileSwitchPage({ 加载中...
+ ) : tools.length === 0 ? ( + ) : ( <> - {/* 透明代理状态显示 - 为所有工具显示 */} + {/* 工具切换 Tab 放在顶部(第三行) */} + + + {tools.map((tool) => ( + + {tool.name} + {tool.name} + + ))} + + + {tools.map((tool) => { + const toolProfiles = profiles[tool.id] || []; + const activeConfig = activeConfigs[tool.id]; + const toolProxyEnabled = isToolProxyEnabled(tool.id); + return ( + + + + ); + })} + + + {/* 透明代理状态显示 - 当前选中工具 */} {selectedSwitchTab && ( - {installedTools.length > 0 ? ( - - - {installedTools.map((tool) => ( - - {tool.name} - {tool.name} - - ))} - - - {installedTools.map((tool) => { - const toolProfiles = profiles[tool.id] || []; - const activeConfig = activeConfigs[tool.id]; - const toolProxyEnabled = isToolProxyEnabled(tool.id); - return ( - - - - ); - })} - - ) : ( - - )} - - {selectedSwitchTab && installedTools.find((tool) => tool.id === selectedSwitchTab) && ( + {selectedSwitchTab && tools.find((tool) => tool.id === selectedSwitchTab) && (
@@ -287,6 +333,42 @@ export function ProfileSwitchPage({ )} + + + + 检测到外部配置改动 + + 发现 {externalChanges.length}{' '} + 项可能由外部修改的配置,请前往「配置管理」处理以避免覆盖冲突。 + + +
+ {externalChanges.slice(0, 4).map((change) => ( +
+
+ {getToolDisplayName(change.tool_id)} / {change.tool_id} +
+
{change.path}
+
+ 检测时间:{new Date(change.detected_at).toLocaleString()} +
+
+ ))} + {externalChanges.length > 4 && ( +
+ 还有 {externalChanges.length - 4} 项未列出,详情请在「配置管理」查看。 +
+ )} +
+ + + + +
+
+ {/* 删除确认对话框 */} ([]); + const [migrations, setMigrations] = useState([]); + const [cleanupResults, setCleanupResults] = useState([]); + const [notifyEnabled, setNotifyEnabled] = useState(true); + const [pollIntervalMs, setPollIntervalMs] = useState(500); + const [nameDialog, setNameDialog] = useState<{ + open: boolean; + toolId: string; + defaultName: string; + }>({ open: false, toolId: '', defaultName: '' }); + const [inputName, setInputName] = useState(''); + + const loadAll = useCallback(async () => { + setLoading(true); + try { + const [changes, mig, watcherOn, cfg] = await Promise.all([ + getExternalChanges().catch(() => []), + getMigrationReport().catch(() => []), + getWatcherStatus().catch(() => false), + getGlobalConfig().catch(() => null), + ]); + setExternalChanges(changes); + setMigrations(mig); + setNotifyEnabled(watcherOn); + if (cfg?.external_poll_interval_ms !== undefined) { + setPollIntervalMs(cfg.external_poll_interval_ms); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadAll(); + }, [loadAll]); + + const saveWatchSettings = useCallback(async () => { + setSavingWatch(true); + try { + await saveWatcherSettings(notifyEnabled, pollIntervalMs); + toast({ + title: '监听设置已保存', + description: notifyEnabled + ? `已开启监听,间隔 ${pollIntervalMs}ms` + : '监听已关闭,仅手动刷新生效', + }); + } catch (error) { + toast({ + title: '保存失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setSavingWatch(false); + } + }, [notifyEnabled, pollIntervalMs, toast]); + + const handleAck = useCallback( + async (toolId: string) => { + try { + await ackExternalChange(toolId); + toast({ title: '已标记为已处理', description: toolId }); + void loadAll(); + } catch (error) { + toast({ title: '操作失败', description: String(error), variant: 'destructive' }); + } + }, + [loadAll, toast], + ); + + const handleImport = useCallback( + async (toolId: string, asNew: boolean) => { + if (asNew) { + const defaultName = `imported-${toolId}`; + setInputName(defaultName); + setNameDialog({ open: true, toolId, defaultName }); + return; + } + + // 覆盖当前:直接用当前激活 profile + const profileName = (await getActiveConfig(toolId)).profile_name || 'default'; + try { + await importNativeChange(toolId, profileName, false); + toast({ + title: '导入完成', + description: `已覆盖 ${profileName}`, + }); + void loadAll(); + } catch (error) { + console.error('[ConfigManagement] import failed', error); + toast({ title: '导入失败', description: String(error), variant: 'destructive' }); + } + }, + [loadAll, toast], + ); + + const handleConfirmImportNew = useCallback(async () => { + const targetName = inputName.trim(); + if (!targetName) { + toast({ + title: '导入失败', + description: '请输入非空的 Profile 名称', + variant: 'destructive', + }); + return; + } + const toolId = nameDialog.toolId; + try { + await importNativeChange(toolId, targetName, true); + toast({ + title: '导入完成', + description: `已导入为 ${targetName}`, + }); + setNameDialog({ open: false, toolId: '', defaultName: '' }); + void loadAll(); + } catch (error) { + console.error('[ConfigManagement] import new failed', error); + toast({ title: '导入失败', description: String(error), variant: 'destructive' }); + } + }, [inputName, loadAll, nameDialog.toolId, toast]); + + const handleCleanup = useCallback(async () => { + setCleaning(true); + try { + const results = await cleanLegacyBackups(); + setCleanupResults(results); + toast({ + title: '清理完成', + description: `成功 ${results.reduce((s, r) => s + r.removed.length, 0)} 项`, + }); + } catch (error) { + toast({ title: '清理失败', description: String(error), variant: 'destructive' }); + } finally { + setCleaning(false); + } + }, [toast]); + + const latestMigrations = useMemo(() => migrations.slice(-10).reverse(), [migrations]); + + // 监听实时外部改动事件,前端直接追加 + useEffect(() => { + let unlisten: (() => void) | undefined; + const setup = async () => { + try { + unlisten = await listen('external-config-changed', (event) => { + setExternalChanges((prev) => { + const payload = event.payload; + const filtered = prev.filter( + (c) => !(c.tool_id === payload.tool_id && c.path === payload.path), + ); + return [...filtered, payload]; + }); + }); + } catch (error) { + toast({ + title: '监听事件失败', + description: String(error), + variant: 'destructive', + }); + } + }; + void setup(); + return () => { + if (unlisten) unlisten(); + }; + }, [toast]); + + // 切换开关即刻应用 + const handleToggleWatch = useCallback( + async (enabled: boolean) => { + const previous = notifyEnabled; + setNotifyEnabled(enabled); + setSavingWatch(true); + try { + await saveWatcherSettings(enabled, pollIntervalMs); + const latest = await getWatcherStatus().catch(() => enabled); + setNotifyEnabled(latest); + toast({ + title: '监听设置已更新', + description: latest + ? `已开启监听,间隔 ${pollIntervalMs}ms` + : '监听已关闭,仅手动刷新生效', + }); + } catch (error) { + setNotifyEnabled(previous); + toast({ + title: '保存失败', + description: String(error), + variant: 'destructive', + }); + } finally { + setSavingWatch(false); + } + }, + [notifyEnabled, pollIntervalMs, toast], + ); + + return ( +
+
+ +
+

配置文件监控

+
+
+ + + + setNameDialog((prev) => (open ? prev : { open: false, toolId: '', defaultName: '' })) + } + > + e.stopPropagation()}> + + 导入为新配置 + +
+
+ 请输入新配置名称(工具:{nameDialog.toolId || '-'}) +
+ setInputName(e.target.value)} + placeholder={nameDialog.defaultName || 'new-profile'} + /> +
+ + + + +
+
+ +
+
+
+
+
实时监听
+

+ 打开后自动监听文件改动;关闭时仅在手动刷新时检查。 +

+
+ +
+
+
+ 轮询间隔 (ms) + setPollIntervalMs(Number(e.target.value) || 0)} + /> +
+ + +
+
+ +
+
+
+
外部改动
+

+ 捕获配置文件的外部修改,可导入为新 Profile、覆盖当前或直接标记已处理。 +

+
+ 0 ? 'destructive' : 'outline'}> + {externalChanges.length} 项 + +
+
+ {externalChanges.length === 0 ? ( +
暂无外部改动
+ ) : ( + externalChanges.map((change) => ( +
+
+ {change.tool_id} + {change.path} + + 检测时间:{new Date(change.detected_at).toLocaleString()} + +
+
+ + + +
+
+ )) + )} +
+
+ +
+
+
+
迁移记录
+

回顾最近的配置迁移动作与结果。

+
+ {latestMigrations.length} +
+
+ {latestMigrations.length === 0 ? ( +
暂无迁移记录
+ ) : ( + latestMigrations.map((record) => ( +
+
+ + {record.tool_id} / {record.profile_name} + + + {record.from_path} + + + {new Date(record.timestamp).toLocaleString()} + +
+ + {record.succeeded ? '成功' : '失败'} + +
+ )) + )} +
+
+ +
+
+
+
清理旧备份
+

+ 一键删除历史备份,释放空间;失败项会单独列出。 +

+
+ +
+
+ {cleanupResults.length === 0 ? ( +
尚未执行清理
+ ) : ( + cleanupResults.map((r) => ( +
+
+ {r.tool_id} + + 清理 {r.removed.length},失败 {r.failed.length} + +
+ {r.failed.length > 0 && 部分失败} +
+ )) + )} +
+
+
+
+ ); +} diff --git a/src/pages/SettingsPage/index.tsx b/src/pages/SettingsPage/index.tsx index 64dc11a..c0b6ff1 100644 --- a/src/pages/SettingsPage/index.tsx +++ b/src/pages/SettingsPage/index.tsx @@ -10,6 +10,7 @@ import { ProxySettingsTab } from './components/ProxySettingsTab'; import { LogSettingsTab } from './components/LogSettingsTab'; import { TransparentProxyMigrationNotice } from './components/TransparentProxyMigrationNotice'; import { AboutTab } from './components/AboutTab'; +import { ConfigManagementTab } from './components/ConfigManagementTab'; import type { GlobalConfig, UpdateInfo } from '@/lib/tauri-commands'; interface SettingsPageProps { @@ -166,6 +167,12 @@ export function SettingsPage({ 基本设置 + + 配置管理 + 代理设置 @@ -222,6 +229,11 @@ export function SettingsPage({ + {/* 配置管理 */} + + + + {/* 透明代理 (迁移提示) */}