diff --git a/docs/HANDOVER-usage-billing.md b/docs/HANDOVER-usage-billing.md index 57df607..d1c2ff2 100644 --- a/docs/HANDOVER-usage-billing.md +++ b/docs/HANDOVER-usage-billing.md @@ -111,7 +111,7 @@ Rust: ## 三、悬而未决(用户已知情,待决定) -1. **OpenCode Go 账单查询**:已实测全部端点 404(`/zen/go/v1/usages`、`/api/usage`、`/api/billing` 等 12+ 路径),cc-switch 源码也不支持 → **结论:无公开 API,不可查**。文档已记录。 +1. **OpenCode Go 账单查询**:~~已实测全部端点 404(`/zen/go/v1/usages`、`/api/usage`、`/api/billing` 等 12+ 路径),cc-switch 源码也不支持 → 结论:无公开 API,不可查~~。**2026-08 推翻**:`GET https://opencode.ai/zen/go/v1/usage`(Bearer + 显式浏览器 UA,否则 Cloudflare 1010 拦截 403)实测 200,已实现为 `plan:opencode_go`(滚动 5h / 周 / 月三窗口)。 2. **Kimi login 配 API key**:可以,代码已支持(api_key 优先于 OAuth)。但 managed 供应商编辑页隐藏 key 输入框(`ProviderEdit.tsx:301` `{!provider.managed && ...}`)。选项:① 用户新建 kimi-coding 预设供应商填 key;② 改 UI 让 managed 显示可选 key 输入。 3. **OAuth refresh**:**已实现**(批次 4)。过期自动用 refresh_token 换新并写回凭据文件;单 flight 锁 + 写回前重读防 CLI 竞态。已知边界:若 access token 未过期但被服务端吊销(罕见),401 不会触发重读重试——暂不处理。 4. **NewAPI 面板自动探测**:打开配置面板时探测 `{base}/api/status` 含 `quota_per_unit` → 自动预选 NewAPI 模板。已提议,未确认。 diff --git a/docs/USAGE-QUERY-ADAPTATION.md b/docs/USAGE-QUERY-ADAPTATION.md index 822404e..712b082 100644 --- a/docs/USAGE-QUERY-ADAPTATION.md +++ b/docs/USAGE-QUERY-ADAPTATION.md @@ -8,11 +8,11 @@ ## 1. 结论速览 -- **KimiSwitch 的账单查询不是空白**:已移植 cc-switch 的「余额查询(Balance)+ 套餐查询(Coding Plan)」两大块,共 **8 种 `usageKinds`**,Rust 侧闭环 + `UsageFooter` 展示,错误通道、keep-last-good、缓存、`detect_provider` 自动识别等核心设计均与 cc-switch 对齐。 +- **KimiSwitch 的账单查询不是空白**:已移植 cc-switch 的「余额查询(Balance)+ 套餐查询(Coding Plan)」两大块,共 **11 种 `usageKinds`**,Rust 侧闭环 + `UsageFooter` 展示,错误通道、keep-last-good、缓存、`detect_provider` 自动识别等核心设计均与 cc-switch 对齐。 - **OpenCode 本身不提供账单/额度查询**。它只负责"定义"供应商与订阅套餐的接入方式(provider id、base URL、认证、模型清单,数据源为 models.dev),"套餐还剩下多少"需要查各家官方 API——这正是 cc-switch(及 KimiSwitch 已移植部分)做的事。 -- **本次调研发现 2 个值得补的能力缺口**: +- **本次调研发现的能力缺口**: 1. **Kimi 开放平台余额查询**(`GET https://api.moonshot.cn/v1/users/me/balance`,Kimi 官方 2026-07 新增公开 API,cc-switch 尚未实现)→ **KimiSwitch 也缺**。 - 2. **OpenCode Go 订阅套餐额度**(5h / 周 / 月 三窗口)→ 无公开查询 API,只有控制台,暂不可程序化。 + 2. **OpenCode Go 订阅套餐额度**(5h / 周 / 月 三窗口)→ 调研时结论为"无公开 API";2026-08 已实测 `GET https://opencode.ai/zen/go/v1/usage`(Bearer)可用并已实现(见 §4.4)。 - 其余缺口(ZenMux / 火山方舟 / 智谱团队版 / JS 脚本引擎 / 官方 OAuth 订阅)为 cc-switch 独有能力,与"做好现有账单查询"的目标匹配度分层,见 §6 路线。 --- @@ -26,7 +26,7 @@ ProviderList (前端) └─ UsageFooter ── invoke("query_provider_usage", { agent, providerName, forceRefresh }) └─ Rust commands.rs:590 ── services::query_kind(kind, base_url, api_key) ├─ balance.rs (余额,5 家) - └─ coding_plan.rs (套餐,3 家) + └─ coding_plan.rs (套餐,4 家) ``` - 前端**永不持有 API key、不直连 HTTP**;所有请求在 Rust 侧用 reqwest 完成(无浏览器 CORS 问题)。 @@ -45,6 +45,7 @@ ProviderList (前端) | `plan:kimi_coding` | **Kimi For Coding** | `GET https://api.kimi.com/coding/v1/usages` | Bearer | ✅ | | `plan:zhipu` | **GLM Coding Plan**(bigmodel.cn / z.ai) | `GET {open.bigmodel.cn\|api.z.ai}/api/monitor/usage/quota/limit` | **Raw key(无 Bearer)** | ✅ | | `plan:minimax` | MiniMax Token Plan (.com/.io) | `GET https://api.minimaxi.com\|.io/v1/api/openplatform/coding_plan/remains` | Bearer | ✅ | +| `plan:opencode_go` | **OpenCode Go** | `GET https://opencode.ai/zen/go/v1/usage` | Bearer(须带浏览器 UA) | ✅ | 已有预设(`providerPresets.ts`):`kimi-coding`(`plan:kimi_coding`)、`zhipu-coding` / `zai-coding`(`plan:zhipu`)、`minimax` / `minimax-token-plan`(`plan:minimax`)、`deepseek` / `stepfun` / `siliconflow` / `novita` / `openrouter`(余额)。 @@ -54,7 +55,7 @@ ProviderList (前端) ```ts interface UsageData { - planName?: string | null; // 套餐名:five_hour / weekly_limit + planName?: string | null; // 套餐名:five_hour / weekly_limit / monthly_limit remaining?: number | null; // 余额:金额(balance)或剩余百分比(plan) total?: number | null; // 总量(plan 恒为 100) used?: number | null; // 已用百分比 0-100(plan) @@ -101,7 +102,7 @@ OpenCode(sst/opencode)通过 `~/.config/opencode/opencode.json` 的 `provide 额度按美元计费价值计算(不同模型折算请求数不同)。超出限额后可选"Use balance"回退到 Zen 余额。 -**查询方式:仅控制台(console),无公开 REST API**。OpenCode 项目本身不做用量查询(无 usage/billing 命令),社区工具(如 cc-switch 的 `usage_script`)也无法覆盖 Go 套餐——这是目前的技术边界,适配时只能提示用户"网页控制台查看"或把 Go 归入"不可查询"类。 +**查询方式(✅ 已实现)**:`GET https://opencode.ai/zen/go/v1/usage`,`Authorization: Bearer `,无 query 参数。响应含 `usage.rolling / usage.weekly / usage.monthly` 三窗口,每窗口 `status / percent / resetsAt`(`percent` = 已用百分比)。2026-08 实测可用;注意该站有 Cloudflare 1010 拦截——reqwest 默认不带 User-Agent 的裸请求会被 403,请求必须显式携带浏览器 UA(见 §4.4)。 ### 3.3 OpenCode 定义 → KimiSwitch 预设的对应关系 @@ -112,8 +113,8 @@ KimiSwitch 的 `providerPresets.ts` 已镜像这套体系(`baseUrl` 与 OpenCo | `kimi-coding` | `https://api.kimi.com/coding/v1` | subscription | `plan:kimi_coding` | ✅ 可查套餐 | | `zhipu-coding` | `https://open.bigmodel.cn/api/coding/paas/v4` | subscription | `plan:zhipu` | ✅ 可查套餐 | | `zai-coding` | `https://api.z.ai/api/coding/paas/v4` | subscription | `plan:zhipu` | ✅ 可查套餐 | -| `opencode-go` | `https://opencode.ai/zen/go/v1` | subscription | **(空)** | ❌ 不可查,见 §4.4 | -| `opencode-zen` | `https://opencode.ai/zen/v1` | pay_as_you_go | **(空)** | ❌ 不可查 | +| `opencode-go` | `https://opencode.ai/zen/go/v1` | subscription | `plan:opencode_go` | ✅ 可查套餐,见 §4.4 | +| `opencode-zen` | `https://opencode.ai/zen/v1` | pay_as_you_go | **(空)** | ❌ 不可查(按量,无公开余额 API) | | `moonshot` | `https://api.moonshot.ai/v1` | pay_as_you_go | **(空)** | ❌ 未挂余额查询,见 §4.3 | --- @@ -185,12 +186,23 @@ KimiSwitch 的 `providerPresets.ts` 已镜像这套体系(`baseUrl` 与 OpenCo - **适配点**:`moonshot` 预设 baseUrl 为 `https://api.moonshot.ai/v1`(国际站),而该端点固定 `api.moonshot.cn`(国内站)。需按 base_url 消歧:host 含 `moonshot.cn` → 查 CN 端点;`moonshot.ai` → 查 `https://api.moonshot.ai/v1/users/me/balance`(国际站对应端点,待实测确认;官方文档仅给出 CN 示例)。 - 新增 `balance:kimi` kind 即可复用现有 balance 路径,前端 `UsageFooter` 自动生效(余额形态显示 `💰 余额 ¥49.59`)。 -### 4.4 OpenCode Go / Zen(❌ 未实现 —— 无公开 API,建议标注"不可查") +### 4.4 OpenCode Go / Zen(✅ Go 已实现 —— `plan:opencode_go`) -- **Go 套餐**:额度窗口见 §3.2,仅控制台可看;`opencode.ai/zen/go/v1` 只有推理端点(`/chat/completions`、`/responses`、`/models`),**无 usage/balance 端点**。 -- **Zen 余额**:充值制,同样仅控制台。 -- **可选替代(不推荐投入)**:通过调用推理端点时服务端返回的 402/429/额度错误头做被动感知,无法拿到数值,价值低。 -- **适配结论**:`opencode-go` / `opencode-zen` 预设维持 `usageKinds` 为空;可在预设 `note`/UI 上提示"额度请在 opencode.ai 控制台查看"。 +- **Go 套餐(✅ 已实现)**:端点 `GET https://opencode.ai/zen/go/v1/usage`,`Authorization: Bearer `,无 query 参数。响应: + + ```json + { + "usage": { + "rolling": { "status": "ok", "percent": 9, "resetsAt": "2026-08-18T06:09:19.735Z" }, + "weekly": { "status": "ok", "percent": 5, "resetsAt": "2026-08-24T00:00:00.735Z" }, + "monthly": { "status": "ok", "percent": 59, "resetsAt": "2026-08-27T03:33:50.735Z" } + } + } + ``` + + 三窗口各解析为一条 `percent_tier`(`percent` = 已用百分比,映射 five_hour / weekly_limit / monthly_limit,前端 `planLabel` 已本地化);`status` 为展示字段,缺失容忍;单窗口缺失只出其余窗口。 +- **坑位(实测)**:opencode.ai 有 Cloudflare 1010 拦截——reqwest 默认不带 User-Agent 的裸请求会被 403(`error code: 1010`),请求必须显式设置浏览器 UA(`coding_plan.rs::BROWSER_USER_AGENT`,经 `get_json_with_ua` 注入)。 +- **Zen 余额**:充值制,仍仅控制台,未实现;`opencode-zen` 预设维持 `usageKinds` 为空。 ### 4.5 其他 cc-switch 独有、KimiSwitch 未移植的能力(按目标匹配度排序) diff --git a/package-lock.json b/package-lock.json index 50cda8e..896adba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kimiswitch", - "version": "0.7.5", + "version": "0.7.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kimiswitch", - "version": "0.7.5", + "version": "0.7.6", "dependencies": { "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-opener": "^2.5.0", diff --git a/package.json b/package.json index 3007186..e71b12c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.7.5", + "version": "0.7.6", "type": "module", "scripts": { "dev": "vite", diff --git a/release-notes-v0.7.6.md b/release-notes-v0.7.6.md new file mode 100644 index 0000000..edb42a3 --- /dev/null +++ b/release-notes-v0.7.6.md @@ -0,0 +1,30 @@ +## v0.7.6 + +### OpenCode Go 套餐用量查询 + +- 新增 **OpenCode Go 套餐额度查询**:供应商卡片用量页脚显示 **5 小时滚动 / 7 天 / 30 天** 三个窗口的已用百分比与重置时间(数据源为 opencode.ai 官方 usage API) +- 数据源:`GET https://opencode.ai/zen/go/v1/usage`(Bearer 认证),直接解析官方 `rolling / weekly / monthly` 三窗口的 `percent` 与 `resetsAt` +- **老配置自动适配**:已配置 OpenCode Go API Key 的现有用户无需任何手动设置,程序按 `base_url` 自动识别并启用用量查询 +- 兼容处理:已适配 opencode.ai 的 Cloudflare 拦截(请求携带浏览器 User-Agent),并对外部接口失败给出可读的错误提示 + +### 插件目录占用错误提示优化 + +- 插件目录被运行中进程占用时的错误提示给出明确指引(底层改进) + +### Downloads by platform + +- **Windows**: `.msi` installer +- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片) +- **Linux**: `.deb` / `.AppImage` / `.rpm` + +### macOS 安装说明(无开发者账号签名,需一次手动绕过) + +1. 下载 `Kimi.Switch_*.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹 +2. 下载本 Release 附带的 `install-macos.sh`,在终端运行: + + ```bash + bash install-macos.sh + ``` + + 脚本会自动清除下载隔离属性并启动应用,只需执行一次,之后正常使用。 +3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗点「打开」;或 系统设置 → 隐私与安全性 → 仍要打开 \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 19dd05f..57807de 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -59,6 +59,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -710,6 +719,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1958,7 +1978,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.7.5" +version = "0.7.6" dependencies = [ "anyhow", "chrono", @@ -1975,9 +1995,11 @@ dependencies = [ "tauri-build", "tauri-plugin-opener", "tauri-plugin-single-instance", + "tempfile", "tokio", "toml 0.8.23", "walkdir", + "zip", ] [[package]] @@ -5484,12 +5506,41 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.12.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7230d8c..68eb756 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kimiswitch" -version = "0.7.5" +version = "0.7.6" description = "Kimi Switch - model config manager" authors = ["codingplan.site"] edition = "2021" @@ -25,7 +25,13 @@ futures-util = "0.3" dirs = "5.0" toml = "0.8" rusqlite = { version = "0.32", features = ["bundled", "chrono"] } -tokio = { version = "1", features = ["sync"] } +tokio = { version = "1", features = ["sync", "fs", "io-util"] } +# Plugin marketplace: zip extraction (deflate-only; no extra compression +# backends needed for plugin archives). +zip = { version = "2", default-features = false, features = ["deflate"] } + +[dev-dependencies] +tempfile = "3" [lib] name = "kimiswitch_lib" diff --git a/src-tauri/src/dashboard.rs b/src-tauri/src/dashboard.rs index aa655b7..ea7eb6d 100644 --- a/src-tauri/src/dashboard.rs +++ b/src-tauri/src/dashboard.rs @@ -345,7 +345,10 @@ pub struct UsageRecord { // Helpers // --------------------------------------------------------------------------- -fn resolve_kimi_home(override_path: Option) -> PathBuf { +/// Resolve the Kimi Code home directory: explicit override, then +/// `KIMI_CODE_HOME`, then the default `~/.kimi-code`. Shared with the plugin +/// marketplace module (`crate::plugins`). +pub(crate) fn resolve_kimi_home(override_path: Option) -> PathBuf { if let Some(p) = override_path { if !p.trim().is_empty() { return PathBuf::from(p); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a328630..5d31419 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod kimi_code_io; pub mod models; pub mod oauth; pub mod pi_io; +pub mod plugins; pub mod services; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; @@ -124,6 +125,11 @@ pub fn run() { dashboard::delete_session, dashboard::delete_workspace, dashboard::get_session_preview, + plugins::get_plugin_marketplace, + plugins::list_installed_plugins, + plugins::install_plugin, + plugins::set_plugin_enabled, + plugins::remove_plugin, ]) .manage(commands::KimiWebState::default()) .build(tauri::generate_context!()) diff --git a/src-tauri/src/plugins.rs b/src-tauri/src/plugins.rs new file mode 100644 index 0000000..7d37f2a --- /dev/null +++ b/src-tauri/src/plugins.rs @@ -0,0 +1,2091 @@ +// --------------------------------------------------------------------------- +// Plugin marketplace backend (Rust side of the KimiSwitch plugin market). +// +// Mirrors the plugin semantics of kimi-code 0.36 +// (packages/agent-core-v2/src/app/plugin/{marketplace,manager,archive, +// manifest,source,store,github-resolver}.ts): +// +// * marketplace catalog: fetched from `KIMI_CODE_PLUGIN_MARKETPLACE_URL` (or +// the public default), cached to `$KIMI_CODE_HOME/plugins/marketplace-cache.json`, +// with the cache as fallback when the fetch fails. +// * installed state: `$KIMI_CODE_HOME/plugins/installed.json`, the exact v1 +// format kimi-code's store.ts writes (`{ "version": 1, "plugins": [...] }`, +// camelCase record fields). Reads/writes are read-modify-write with an atomic +// tmp-file + rename swap, so the app never corrupts the file kimi-code shares. +// * install: resolve the source (zip URL / GitHub repo URL), stream-download +// the zip, extract behind a path-traversal guard, locate the plugin manifest +// (`kimi.plugin.json` or `.kimi-plugin/plugin.json`), validate the plugin +// name, publish atomically to `plugins/managed//` (staging dir + rename, +// previous copy moved aside and restored on failure), then record it. +// * enable/disable toggles `enabled` + refreshes `updatedAt`; remove deletes +// only the installed.json record, keeping the managed files on disk. +// +// Timestamps use the same ISO-8601 UTC millis format as kimi-code's +// `new Date().toISOString()`. +// --------------------------------------------------------------------------- + +use chrono::{SecondsFormat, Utc}; +use futures_util::StreamExt; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use tokio::io::AsyncWriteExt; + +const DEFAULT_MARKETPLACE_URL: &str = "https://code.kimi.com/kimi-code/plugins/marketplace.json"; +const MARKETPLACE_URL_ENV: &str = "KIMI_CODE_PLUGIN_MARKETPLACE_URL"; +const MARKETPLACE_CACHE_REL: &str = "plugins/marketplace-cache.json"; +const INSTALLED_REL: &str = "plugins/installed.json"; +const CATALOG_FETCH_TIMEOUT_SECS: u64 = 60; +const ZIP_DOWNLOAD_TIMEOUT_SECS: u64 = 300; + +// --------------------------------------------------------------------------- +// Public types (Tauri command contract, serde camelCase) +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PluginMarketplaceResult { + pub fetched_at: String, + pub from_cache: bool, + pub entries: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MarketplaceEntry { + pub id: String, + pub display_name: String, + pub version: Option, + pub description: Option, + pub keywords: Vec, + pub homepage: Option, + pub tier: String, + pub source: String, + pub capability_id: Option, + pub installed: Option, + pub update_available: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct InstalledPluginInfo { + pub id: String, + pub root: String, + pub source: String, + pub enabled: bool, + pub version: Option, + pub installed_at: Option, + pub updated_at: Option, + pub is_marketplace: bool, +} + +// --------------------------------------------------------------------------- +// installed.json store — mirrors kimi-code store.ts exactly +// --------------------------------------------------------------------------- + +/// `{ "version": 1, "plugins": [...] }` — top-level is an object with a +/// `plugins` array (kimi-code `InstalledFile`). Unknown top-level keys are +/// preserved across read-modify-write so a newer kimi-code never loses data. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct InstalledFile { + #[serde(default = "default_version")] + pub version: u8, + pub plugins: Vec, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +fn default_version() -> u8 { + 1 +} + +impl Default for InstalledFile { + fn default() -> Self { + InstalledFile { + version: 1, + plugins: Vec::new(), + extra: serde_json::Map::new(), + } + } +} + +/// One entry of installed.json — field names/casing must match kimi-code +/// `InstalledRecord` (`installedAt`, `updatedAt`, `originalSource`, +/// `capabilities`, `github`). Unknown record fields are preserved. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct InstalledRecord { + pub id: String, + pub root: String, + /// "local-path" | "zip-url" | "github" (kimi-code `PluginSource`). + pub source: String, + pub enabled: bool, + pub installed_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original_source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// kimi-code `PluginGithubMetadata` (`installedSha` omitted when unknown, +/// matching JSON.stringify of an undefined field). +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GithubMetadata { + pub owner: String, + pub repo: String, + pub r#ref: GithubRef, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub installed_sha: Option, +} + +/// kimi-code `PluginGithubRef` — kind is one of "branch" | "tag" | "sha". +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GithubRef { + pub kind: String, + pub value: String, +} + +fn read_installed(home: &Path) -> Result { + let path = home.join(INSTALLED_REL); + if !path.is_file() { + return Ok(InstalledFile::default()); + } + // serde already enforces store.ts's structural requirements (`plugins` + // must be present and an array); a parse failure surfaces as an error + // just like kimi-code's readInstalled does. + let text = fs::read_to_string(&path).map_err(|e| format!("failed to read {}: {e}", path.display()))?; + serde_json::from_str(&text).map_err(|e| format!("failed to parse {}: {e}", path.display())) +} + +/// Atomic read-modify-write target: tmp file in the same directory, then rename. +fn write_installed(home: &Path, data: &InstalledFile) -> Result<(), String> { + let path = home.join(INSTALLED_REL); + let tmp = path.with_file_name("installed.json.tmp"); + fs::create_dir_all(home.join("plugins")) + .map_err(|e| format!("failed to create plugins dir: {e}"))?; + let text = serde_json::to_string_pretty(data).map_err(|e| format!("failed to serialize installed.json: {e}"))?; + fs::write(&tmp, text).map_err(|e| format!("failed to write {}: {e}", tmp.display()))?; + fs::rename(&tmp, &path).map_err(|e| format!("failed to replace {}: {e}", path.display())) +} + +// --------------------------------------------------------------------------- +// Marketplace catalog +// --------------------------------------------------------------------------- + +fn marketplace_url() -> Result { + if let Ok(env) = std::env::var(MARKETPLACE_URL_ENV) { + let trimmed = env.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_string()); + } + } + Ok(DEFAULT_MARKETPLACE_URL.to_string()) +} + +/// Cache payload written to `marketplace-cache.json`: fetch time + the raw +/// catalog object, so a later parse stays independent of the catalog URL. +#[derive(Debug, Serialize, Deserialize)] +struct CatalogCache { + #[serde(rename = "fetchedAt")] + fetched_at: String, + catalog: serde_json::Value, +} + +fn write_catalog_cache(home: &Path, fetched_at: &str, catalog: &serde_json::Value) -> Result<(), String> { + let path = home.join(MARKETPLACE_CACHE_REL); + let tmp = path.with_file_name("marketplace-cache.json.tmp"); + fs::create_dir_all(home.join("plugins")) + .map_err(|e| format!("failed to create plugins dir: {e}"))?; + let cache = CatalogCache { + fetched_at: fetched_at.to_string(), + catalog: catalog.clone(), + }; + let text = serde_json::to_string_pretty(&cache).map_err(|e| format!("failed to serialize marketplace cache: {e}"))?; + fs::write(&tmp, text).map_err(|e| format!("failed to write marketplace cache: {e}"))?; + fs::rename(&tmp, &path).map_err(|e| format!("failed to replace marketplace cache: {e}")) +} + +fn read_catalog_cache(home: &Path) -> Result, String> { + let path = home.join(MARKETPLACE_CACHE_REL); + if !path.is_file() { + return Ok(None); + } + let text = fs::read_to_string(&path).map_err(|e| format!("failed to read marketplace cache: {e}"))?; + let cache: CatalogCache = + serde_json::from_str(&text).map_err(|e| format!("marketplace cache is corrupted: {e}"))?; + Ok(Some(cache)) +} + +fn non_blank_str(obj: &serde_json::Map, key: &str) -> Option { + obj.get(key) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + +/// Parse + normalize a catalog (`{version?, plugins: [...]}`) into entries. +/// Field aliases follow kimi-code (`displayName`/`name`, +/// `source`/`url`/`downloadUrl`, `description`/`shortDescription`, +/// `homepage`/`websiteURL`); entry sources are resolved against `catalog_url`. +fn parse_catalog_from_value(raw: &serde_json::Value, catalog_url: &str) -> Result, String> { + let obj = raw + .as_object() + .ok_or_else(|| "plugin marketplace must be an object".to_string())?; + let plugins = obj + .get("plugins") + .and_then(|p| p.as_array()) + .ok_or_else(|| "plugin marketplace must contain a \"plugins\" array".to_string())?; + let mut out = Vec::with_capacity(plugins.len()); + for (i, entry) in plugins.iter().enumerate() { + out.push(parse_marketplace_entry(entry, i, catalog_url)?); + } + Ok(out) +} + +fn parse_marketplace_entry(value: &serde_json::Value, index: usize, catalog_url: &str) -> Result { + let obj = value + .as_object() + .ok_or_else(|| format!("plugin marketplace entry {} must be an object", index + 1))?; + let id = non_blank_str(obj, "id") + .ok_or_else(|| format!("plugin marketplace entry {} must define \"id\"", index + 1))?; + let source_raw = non_blank_str(obj, "source") + .or_else(|| non_blank_str(obj, "url")) + .or_else(|| non_blank_str(obj, "downloadUrl")) + .ok_or_else(|| format!("plugin marketplace entry {id} must define \"source\""))?; + let source = resolve_entry_source(&source_raw, catalog_url); + let display_name = non_blank_str(obj, "displayName") + .or_else(|| non_blank_str(obj, "name")) + .unwrap_or_else(|| id.clone()); + // kimi-code validates tier against {official, curated}; we accept those and + // fall back to "curated" for anything else / missing (lenient by design). + let tier = match non_blank_str(obj, "tier").as_deref() { + Some("official") | Some("curated") => non_blank_str(obj, "tier").unwrap(), + _ => "curated".to_string(), + }; + let keywords = obj + .get("keywords") + .and_then(|k| k.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + Ok(MarketplaceEntry { + id, + display_name, + version: non_blank_str(obj, "version"), + description: non_blank_str(obj, "description").or_else(|| non_blank_str(obj, "shortDescription")), + keywords, + homepage: non_blank_str(obj, "homepage").or_else(|| non_blank_str(obj, "websiteURL")), + tier, + source, + capability_id: non_blank_str(obj, "capabilityId"), + installed: None, + update_available: false, + }) +} + +/// Resolve an entry source against the catalog location (kimi-code +/// `resolveEntrySource`): http(s) as-is, `file://` decoded, `~` expanded, +/// absolute paths as-is, relative paths joined to the catalog URL/file. +fn resolve_entry_source(source: &str, catalog_url: &str) -> String { + let trimmed = source.trim(); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + return trimmed.to_string(); + } + if let Some(rest) = trimmed.strip_prefix("file://") { + return percent_decode(rest); + } + if trimmed == "~" { + if let Some(home) = dirs::home_dir() { + return home.to_string_lossy().to_string(); + } + } else if let Some(rest) = trimmed.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest).to_string_lossy().to_string(); + } + } + let p = PathBuf::from(trimmed); + if p.is_absolute() { + return trimmed.to_string(); + } + if catalog_url.starts_with("http://") || catalog_url.starts_with("https://") { + resolve_relative_url(catalog_url, trimmed) + } else { + // Local catalog file: relative to its directory. Resolve lexically so + // "./" and "../" components are normalized (Path::join keeps them). + let catalog = PathBuf::from(catalog_url); + let base_dir = catalog.parent().unwrap_or(Path::new(".")); + lexical_join(base_dir, trimmed).to_string_lossy().to_string() + } +} + +/// Lexically resolve `rel` against `base`, dropping "." and applying "..". +fn lexical_join(base: &Path, rel: &str) -> PathBuf { + use std::path::Component; + let mut out: Vec = base.components().collect(); + for comp in Path::new(rel).components() { + match comp { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + c => out.push(c), + } + } + out.into_iter().collect() +} + +/// URL resolution equivalent of `new URL(rel, base).toString()` for the +/// relative-source cases the marketplace actually uses (`./x`, `../x`, +/// `/abs`, `//host/x`). Hand-rolled to avoid a `url` crate dependency. +fn resolve_relative_url(base: &str, rel: &str) -> String { + if rel.starts_with("//") { + let scheme = base.split("://").next().unwrap_or("https"); + return format!("{scheme}:{rel}"); + } + let (head, base_path) = match base.split_once("://") { + Some((scheme, rest)) => { + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + (format!("{scheme}://{authority}"), path.to_string()) + } + None => (String::new(), base.to_string()), + }; + if rel.starts_with('/') { + return format!("{head}{rel}"); + } + let base_dir = base_path + .rsplit_once('/') + .map(|(d, _)| d.to_string()) + .unwrap_or_default(); + let joined = if base_dir.is_empty() { + format!("/{rel}") + } else { + format!("{base_dir}/{rel}") + }; + let normalized = normalize_url_path(&joined); + if head.is_empty() { + normalized + } else { + format!("{head}{normalized}") + } +} + +fn normalize_url_path(p: &str) -> String { + let mut out: Vec<&str> = Vec::new(); + for seg in p.split('/') { + match seg { + "" | "." => {} + ".." => { + out.pop(); + } + s => out.push(s), + } + } + if out.is_empty() { + "/".to_string() + } else { + format!("/{}", out.join("/")) + } +} + +// --------------------------------------------------------------------------- +// Version comparison (simple semver — unparseable versions never update) +// --------------------------------------------------------------------------- + +/// Split "1.2.3" / "v1.2" / "1.2.3-beta.1" into numeric components; the +/// pre-release suffix is ignored. Returns None when no numeric version parses. +fn parse_simple_semver(s: &str) -> Option> { + let s = s.trim(); + let s = s.strip_prefix('v').or_else(|| s.strip_prefix('V')).unwrap_or(s); + if s.is_empty() { + return None; + } + let mut parts = Vec::new(); + for seg in s.split('.') { + let digits: String = seg.chars().take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + parts.push(digits.parse::().ok()?); + if digits.len() != seg.len() { + break; // pre-release suffix — ignore the remainder + } + } + Some(parts) +} + +fn compare_simple_semver(a: &str, b: &str) -> Option { + let a = parse_simple_semver(a)?; + let b = parse_simple_semver(b)?; + for i in 0..a.len().max(b.len()) { + let av = a.get(i).copied().unwrap_or(0); + let bv = b.get(i).copied().unwrap_or(0); + if av != bv { + return Some(av.cmp(&bv)); + } + } + Some(Ordering::Equal) +} + +// --------------------------------------------------------------------------- +// Source resolution (kimi-code source.ts + github-resolver.ts) +// --------------------------------------------------------------------------- + +#[derive(Debug)] +enum ResolvedSource { + Github { + owner: String, + repo: String, + r#ref: Option, + }, + ZipUrl(String), + LocalPath(String), +} + +fn resolve_install_source(source: &str) -> Result { + let trimmed = source.trim(); + if let Some(github) = parse_github_url(trimmed) { + return Ok(github); + } + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + return Ok(ResolvedSource::ZipUrl(trimmed.to_string())); + } + if !PathBuf::from(trimmed).is_absolute() { + return Err(format!("plugin source must be an absolute path or a URL (got {source:?})")); + } + Ok(ResolvedSource::LocalPath(trimmed.to_string())) +} + +fn is_sha_like(value: &str) -> bool { + (7..=40).contains(&value.len()) + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Recognizes the four kimi-code GitHub forms (source.ts `parseGithubUrl`): +/// bare `https://github.com/o/r`, `.../tree/`, `.../releases/tag/`, +/// `.../commit/` (plus `www.` and `.git` normalization). +fn parse_github_url(raw: &str) -> Option { + let rest = raw.strip_prefix("https://")?; + let rest = rest.strip_prefix("www.github.com").or_else(|| rest.strip_prefix("github.com"))?; + if !rest.starts_with('/') { + return None; + } + let path = &rest[1..]; + let path = path.split(['?', '#']).next().unwrap_or(path); + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let owner = *segments.first()?; + let repo_raw = *segments.get(1)?; + if owner.is_empty() { + return None; + } + let repo = repo_raw.strip_suffix(".git").unwrap_or(repo_raw).to_string(); + match &segments[2..] { + [] => Some(ResolvedSource::Github { + owner: owner.to_string(), + repo, + r#ref: None, + }), + [head, _, ..] if *head == "tree" => { + let value = decode_ref_segments(&segments[3..]); + if value.is_empty() { + return None; + } + let kind = if is_sha_like(&value) { "sha" } else { "branch" }; + Some(ResolvedSource::Github { + owner: owner.to_string(), + repo, + r#ref: Some(GithubRef { + kind: kind.to_string(), + value, + }), + }) + } + [head, second, _, ..] if *head == "releases" && *second == "tag" => { + let value = decode_ref_segments(&segments[4..]); + if value.is_empty() { + return None; + } + Some(ResolvedSource::Github { + owner: owner.to_string(), + repo, + r#ref: Some(GithubRef { + kind: "tag".to_string(), + value, + }), + }) + } + [head, _, ..] if *head == "commit" => { + let value = decode_ref_segments(&segments[3..]); + if value.is_empty() { + return None; + } + Some(ResolvedSource::Github { + owner: owner.to_string(), + repo, + r#ref: Some(GithubRef { + kind: "sha".to_string(), + value, + }), + }) + } + _ => None, + } +} + +fn decode_ref_segments(segments: &[&str]) -> String { + segments + .iter() + .map(|s| percent_decode(s)) + .collect::>() + .join("/") +} + +/// kimi-code `codeloadUrl`: sha/branch → `zip/`, tag → `zip/refs/tags/`. +fn github_zip_url(owner: &str, repo: &str, r#ref: &GithubRef) -> String { + let encoded = encode_ref_path(&r#ref.value); + let base = format!("https://codeload.github.com/{owner}/{repo}/zip"); + if r#ref.kind == "tag" { + format!("{base}/refs/tags/{encoded}") + } else { + format!("{base}/{encoded}") + } +} + +fn encode_ref_path(value: &str) -> String { + value + .split('/') + .map(encode_uri_component) + .collect::>() + .join("/") +} + +fn encode_uri_component(s: &str) -> String { + let mut out = String::new(); + for &b in s.as_bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')') { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3]) { + if let Ok(v) = u8::from_str_radix(hex, 16) { + out.push(v); + i += 3; + continue; + } + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +fn http_client(timeout_secs: u64) -> Result { + reqwest::Client::builder() + .user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION"))) + .timeout(std::time::Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}")) +} + +/// GET + parse + structural validation in one step so a malformed catalog +/// falls through to the cache like a network failure does. +async fn fetch_and_parse_catalog(url: &str) -> Result { + let client = http_client(CATALOG_FETCH_TIMEOUT_SECS)?; + let resp = client + .get(url) + .send() + .await + .map_err(|e| format!("failed to fetch plugin marketplace: {e}"))?; + if !resp.status().is_success() { + return Err(format!("plugin marketplace returned HTTP {}", resp.status())); + } + let raw: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("plugin marketplace is not valid JSON: {e}"))?; + let _ = parse_catalog_from_value(&raw, url)?; + Ok(raw) +} + +/// kimi-code `tryResolveLatestReleaseTag`: follow `/releases/latest` manually +/// (redirect not followed) and extract the tag from the Location header. +async fn resolve_latest_release_tag(owner: &str, repo: &str) -> Result, String> { + let url = format!("https://github.com/{owner}/{repo}/releases/latest"); + let client = reqwest::Client::builder() + .user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION"))) + .timeout(std::time::Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + let resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("could not look up latest release of {owner}/{repo}: {e}"))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if resp.status() != reqwest::StatusCode::MOVED_PERMANENTLY && resp.status() != reqwest::StatusCode::FOUND { + return Err(format!( + "could not look up latest release of {owner}/{repo}: HTTP {} ({url})", + resp.status() + )); + } + let location = resp + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let Some(location) = location else { + return Ok(None); + }; + let Some(start) = location.find("/releases/tag/") else { + return Ok(None); + }; + let rest = &location[start + "/releases/tag/".len()..]; + let end = rest.find(['?', '#']).unwrap_or(rest.len()); + Ok(Some(percent_decode(&rest[..end]))) +} + +/// kimi-code `resolveGithubSource`: explicit ref → codeload zip URL; no ref → +/// latest release tag, else HEAD-probe the default branch. +async fn resolve_github_zip_url( + owner: &str, + repo: &str, + r#ref: &Option, +) -> Result<(String, GithubRef), String> { + if let Some(r) = r#ref { + return Ok((github_zip_url(owner, repo, r), r.clone())); + } + if let Some(tag) = resolve_latest_release_tag(owner, repo).await? { + let r = GithubRef { + kind: "tag".to_string(), + value: tag, + }; + return Ok((github_zip_url(owner, repo, &r), r)); + } + let head_url = format!("https://codeload.github.com/{owner}/{repo}/zip/HEAD"); + let client = http_client(10)?; + let resp = client + .head(&head_url) + .send() + .await + .map_err(|e| format!("could not access `{owner}/{repo}`: {e}"))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(format!("repository `{owner}/{repo}` not found or not accessible")); + } + if !resp.status().is_success() { + return Err(format!( + "could not access `{owner}/{repo}`: HTTP {}", + resp.status() + )); + } + let r = GithubRef { + kind: "branch".to_string(), + value: "HEAD".to_string(), + }; + Ok((github_zip_url(owner, repo, &r), r)) +} + +/// Stream the zip to a temp file (300s timeout, no full in-memory buffering). +async fn download_to_temp_file(url: &str) -> Result { + let client = http_client(ZIP_DOWNLOAD_TIMEOUT_SECS)?; + let resp = client + .get(url) + .send() + .await + .map_err(|e| format!("failed to download {url}: {e}"))?; + if !resp.status().is_success() { + return Err(format!("failed to download {url}: HTTP {}", resp.status())); + } + let tmp = std::env::temp_dir().join(format!( + "kimi-plugin-download-{}-{}.zip", + std::process::id(), + unique_suffix() + )); + let result = async { + let mut file = tokio::fs::File::create(&tmp) + .await + .map_err(|e| format!("failed to create temp file: {e}"))?; + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("download interrupted: {e}"))?; + file.write_all(&chunk) + .await + .map_err(|e| format!("failed to write download: {e}"))?; + } + file.flush().await.map_err(|e| format!("failed to flush download: {e}"))?; + Ok::<(), String>(()) + } + .await; + if let Err(e) = result { + let _ = fs::remove_file(&tmp); + return Err(e); + } + Ok(tmp) +} + +// --------------------------------------------------------------------------- +// Zip extraction with a path-traversal guard +// --------------------------------------------------------------------------- + +/// Normalize a zip entry name and reject anything that could escape the +/// destination: `..` components, absolute paths, drive letters, and the +/// backslash variants Windows zips sometimes carry. Mirrors kimi-code +/// archive.ts's containment check (destPath must stay under destDir). +fn safe_zip_name(name: &str) -> Result { + let normalized = name.replace('\\', "/"); + if normalized.starts_with('/') { + return Err(format!("path traversal: zip entry {name:?} is absolute")); + } + if normalized.contains(':') { + return Err(format!( + "path traversal: zip entry {name:?} contains a drive-letter component" + )); + } + let mut out = PathBuf::new(); + for comp in normalized.split('/') { + match comp { + "" | "." => {} + ".." => return Err(format!("path traversal: zip entry {name:?} contains '..'")), + c => out.push(c), + } + } + if out.as_os_str().is_empty() { + return Err(format!("path traversal: zip entry {name:?} is not a valid relative path")); + } + Ok(out) +} + +fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> { + fs::create_dir_all(dest).map_err(|e| format!("failed to create extraction dir: {e}"))?; + let file = fs::File::open(zip_path).map_err(|e| format!("failed to open zip: {e}"))?; + let mut archive = + zip::ZipArchive::new(file).map_err(|e| format!("failed to open zip: {e}"))?; + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("failed to read zip entry {i}: {e}"))?; + let entry_name = entry.name().to_string(); + let rel = safe_zip_name(&entry_name)?; + let dest_path = dest.join(&rel); + // Defense in depth: the sanitized path must still stay under `dest`. + if !dest_path.starts_with(dest) { + return Err(format!( + "path traversal: zip entry {entry_name:?} escapes the destination" + )); + } + if entry.is_dir() { + fs::create_dir_all(&dest_path).map_err(|e| format!("failed to create dir: {e}"))?; + continue; + } + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("failed to create dir: {e}"))?; + } + let mut out = fs::File::create(&dest_path).map_err(|e| format!("failed to create file: {e}"))?; + std::io::copy(&mut entry, &mut out).map_err(|e| format!("failed to extract entry: {e}"))?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Manifest handling (kimi-code manifest.ts) +// --------------------------------------------------------------------------- + +fn plugin_name_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\A[a-z0-9][a-z0-9_-]{0,63}\z").unwrap()) +} + +fn validate_plugin_name(name: &str) -> Result<(), String> { + if plugin_name_re().is_match(name) { + Ok(()) + } else { + Err(format!( + "plugin name {name:?} must match ^[a-z0-9][a-z0-9_-]{{0,63}}$" + )) + } +} + +struct ParsedManifest { + name: String, + version: Option, +} + +fn parse_manifest(root: &Path) -> Result { + let root_manifest = root.join("kimi.plugin.json"); + let dir_manifest = root.join(".kimi-plugin").join("plugin.json"); + let path = if root_manifest.is_file() { + root_manifest + } else if dir_manifest.is_file() { + dir_manifest + } else { + return Err( + "no plugin manifest (kimi.plugin.json or .kimi-plugin/plugin.json) found".to_string(), + ); + }; + let text = fs::read_to_string(&path).map_err(|e| format!("failed to read manifest: {e}"))?; + let v: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("manifest is not valid JSON: {e}"))?; + let name = v + .get("name") + .and_then(|n| n.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "\"name\" is required in the plugin manifest".to_string())?; + validate_plugin_name(name)?; + let version = v + .get("version") + .and_then(|x| x.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + Ok(ParsedManifest { + name: name.to_string(), + version, + }) +} + +/// Best-effort version read (None on any error) for update checks. +fn read_manifest_version(root: &Path) -> Option { + parse_manifest(root).ok().and_then(|m| m.version) +} + +fn has_manifest(dir: &Path) -> bool { + dir.join("kimi.plugin.json").is_file() || dir.join(".kimi-plugin").join("plugin.json").is_file() +} + +/// kimi-code `detectPluginRoot`: manifest at the staging root, else a single +/// child directory that carries the manifest. +fn detect_plugin_root(staging: &Path) -> Result { + if has_manifest(staging) { + return Ok(staging.to_path_buf()); + } + let mut subdirs = Vec::new(); + for entry in fs::read_dir(staging).map_err(|e| format!("failed to read staging dir: {e}"))? { + let entry = entry.map_err(|e| format!("failed to read staging dir: {e}"))?; + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + subdirs.push(entry.path()); + } + } + if subdirs.len() == 1 && has_manifest(&subdirs[0]) { + return Ok(subdirs[0].clone()); + } + Err( + "no plugin manifest (kimi.plugin.json or .kimi-plugin/plugin.json) found at the archive root or its single subdirectory" + .to_string(), + ) +} + +// --------------------------------------------------------------------------- +// Install pipeline (kimi-code manager.ts) +// --------------------------------------------------------------------------- + +/// Serializes read-modify-write of installed.json inside this process. +static INSTALLED_LOCK: Mutex<()> = Mutex::new(()); + +static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn unique_suffix() -> String { + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{}-{nanos}-{n}", std::process::id()) +} + +fn now_iso() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} + +/// Error for the "move existing managed copy aside" step. On Windows a +/// directory cannot be renamed while any process holds a handle inside it +/// (os error 32) — typically a running kimi-code session or a plugin MCP +/// server child process has files open under the old managed root. Give +/// actionable guidance instead of a bare OS error. +fn move_aside_error(e: &std::io::Error) -> String { + if e.raw_os_error() == Some(32) { + return "plugin directory is in use by another process (os error 32): \ + a running kimi-code session or plugin MCP server has files open \ + under the old plugin directory. Close kimi-code (including any \ + running sessions) and retry the install/update." + .to_string(); + } + format!("failed to move existing plugin aside: {e}") +} + +/// Extract → detect root → parse manifest → validate expected id → publish +/// atomically → record in installed.json. `source_type` is what the record +/// stores ("zip-url" | "github"). On any failure the new managed copy is +/// removed and the previous one restored. +fn install_from_zip( + home: &Path, + zip_path: &Path, + expected_id: Option<&str>, + original_source: &str, + source_type: &str, + github: Option, +) -> Result { + let managed_dir = home.join("plugins").join("managed"); + fs::create_dir_all(&managed_dir).map_err(|e| format!("failed to create managed dir: {e}"))?; + + let staging = managed_dir.join(format!(".tmp-install-{}", unique_suffix())); + if let Err(e) = extract_zip(zip_path, &staging) { + let _ = fs::remove_dir_all(&staging); + return Err(e); + } + let source_root = match detect_plugin_root(&staging) { + Ok(root) => root, + Err(e) => { + let _ = fs::remove_dir_all(&staging); + return Err(e); + } + }; + let parsed = match parse_manifest(&source_root) { + Ok(m) => m, + Err(e) => { + let _ = fs::remove_dir_all(&staging); + return Err(e); + } + }; + let id = parsed.name.to_lowercase(); + if let Some(expected) = expected_id { + if expected.to_lowercase() != id { + let _ = fs::remove_dir_all(&staging); + return Err(format!( + "plugin manifest name {:?} does not match the expected id {expected:?}", + parsed.name + )); + } + } + + // Publish atomically: move the old managed copy aside, rename the new one + // into place; keep the old copy until installed.json is written. + let managed_root = managed_dir.join(&id); + let previous = managed_dir.join(format!("{id}.previous-{}", unique_suffix())); + let mut moved_previous = false; + let target = if source_root == staging { + staging.clone() + } else { + source_root.clone() + }; + let publish = (|| -> Result<(), String> { + if let Err(e) = fs::rename(&target, &managed_root) { + if !managed_root.exists() { + return Err(format!("failed to publish plugin to {}: {e}", managed_root.display())); + } + fs::rename(&managed_root, &previous).map_err(|e| move_aside_error(&e))?; + moved_previous = true; + fs::rename(&target, &managed_root).map_err(|e| format!("failed to publish plugin: {e}"))?; + } + Ok(()) + })(); + if let Err(e) = publish { + let _ = fs::remove_dir_all(&staging); + // The old copy may already be parked at `previous` — restore it. + if moved_previous { + let _ = fs::rename(&previous, &managed_root); + } + return Err(e); + } + if source_root != staging { + let _ = fs::remove_dir_all(&staging); + } + + let record = match (|| -> Result { + let _guard = INSTALLED_LOCK.lock().unwrap(); + let mut file = read_installed(home)?; + let now = now_iso(); + let existing = file.plugins.iter().find(|r| r.id == id).cloned(); + let record = InstalledRecord { + id: id.clone(), + root: managed_root.to_string_lossy().to_string(), + source: source_type.to_string(), + enabled: existing.as_ref().map(|r| r.enabled).unwrap_or(true), + installed_at: existing + .as_ref() + .map(|r| r.installed_at.clone()) + .unwrap_or_else(|| now.clone()), + updated_at: Some(now), + original_source: Some(original_source.to_string()), + capabilities: existing.as_ref().and_then(|r| r.capabilities.clone()), + github, + extra: serde_json::Map::new(), + }; + if let Some(i) = file.plugins.iter().position(|r| r.id == id) { + file.plugins[i] = record.clone(); + } else { + file.plugins.push(record.clone()); + } + write_installed(home, &file)?; + Ok(record) + })() { + Ok(record) => record, + Err(e) => { + // Rollback the publish: remove the new copy, restore the old one. + let _ = fs::remove_dir_all(&managed_root); + if moved_previous { + let _ = fs::rename(&previous, &managed_root); + } + return Err(e); + } + }; + let _ = fs::remove_dir_all(&previous); + Ok(installed_info(&record, home)) +} + +// --------------------------------------------------------------------------- +// Marketplace merge helpers +// --------------------------------------------------------------------------- + +/// Read the cached catalog (best-effort) into normalized entries, used to +/// decide `is_marketplace` without hitting the network. +fn cached_catalog_entries(home: &Path) -> Vec { + let Ok(Some(cache)) = read_catalog_cache(home) else { + return Vec::new(); + }; + match marketplace_url().and_then(|url| parse_catalog_from_value(&cache.catalog, &url)) { + Ok(entries) => entries, + Err(_) => Vec::new(), + } +} + +/// is_marketplace: the record's id / source / originalSource corresponds to a +/// catalog entry. +fn is_marketplace_install(home: &Path, record: &InstalledRecord) -> bool { + let entries = cached_catalog_entries(home); + entries.iter().any(|e| { + e.id == record.id + || record.original_source.as_deref() == Some(e.source.as_str()) + || record.source == e.source + }) +} + +fn installed_info(record: &InstalledRecord, home: &Path) -> InstalledPluginInfo { + InstalledPluginInfo { + id: record.id.clone(), + root: record.root.clone(), + source: record.source.clone(), + enabled: record.enabled, + version: read_manifest_version(&PathBuf::from(&record.root)), + installed_at: Some(record.installed_at.clone()), + updated_at: record.updated_at.clone(), + is_marketplace: is_marketplace_install(home, record), + } +} + +/// Merge a parsed catalog with local installed state (kimi-code +/// `computeUpdateStatus`: update only on strict latest > installed). +fn build_marketplace_result( + home: &Path, + raw: &serde_json::Value, + catalog_url: &str, + fetched_at: String, + from_cache: bool, +) -> Result { + let entries = parse_catalog_from_value(raw, catalog_url)?; + let installed = read_installed(home)?; + let mut by_id: HashMap = HashMap::new(); + for record in &installed.plugins { + by_id.insert(record.id.to_lowercase(), record); + } + let mut out = Vec::with_capacity(entries.len()); + for entry in entries { + let (installed, update_available) = match by_id.get(&entry.id.to_lowercase()) { + Some(record) => { + let version = read_manifest_version(&PathBuf::from(&record.root)); + let update = match (&entry.version, &version) { + (Some(latest), Some(local)) => { + compare_simple_semver(latest, local) == Some(Ordering::Greater) + } + _ => false, + }; + let info = InstalledPluginInfo { + id: record.id.clone(), + root: record.root.clone(), + source: record.source.clone(), + enabled: record.enabled, + version, + installed_at: Some(record.installed_at.clone()), + updated_at: record.updated_at.clone(), + // Matched against a catalog entry by id → marketplace install. + is_marketplace: true, + }; + (Some(info), update) + } + None => (None, false), + }; + out.push(MarketplaceEntry { + installed, + update_available, + ..entry + }); + } + Ok(PluginMarketplaceResult { + fetched_at, + from_cache, + entries: out, + }) +} + +/// Backstop for capabilityId entries (the frontend already blocks them): if the +/// cached catalog says `expected_id` is a built-in capability, refuse. +fn find_catalog_capability_entry(home: &Path, id: &str) -> Result, String> { + let Some(cache) = read_catalog_cache(home)? else { + return Ok(None); + }; + let entries = parse_catalog_from_value(&cache.catalog, &marketplace_url()?)?; + Ok(entries + .into_iter() + .find(|e| e.id == id && e.capability_id.as_deref().is_some_and(|c| !c.is_empty()))) +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn get_plugin_marketplace( + home_override: Option, + refresh: Option, +) -> Result { + // `refresh` is accepted for API compatibility; the fetch is always + // attempted and the cache only serves as a fallback on failure. + let _ = refresh; + let home = crate::dashboard::resolve_kimi_home(home_override); + let url = marketplace_url()?; + match fetch_and_parse_catalog(&url).await { + Ok(raw) => { + let fetched_at = now_iso(); + if let Err(e) = write_catalog_cache(&home, &fetched_at, &raw) { + eprintln!("[plugins] failed to write marketplace cache: {e}"); + } + build_marketplace_result(&home, &raw, &url, fetched_at, false) + } + Err(fetch_err) => match read_catalog_cache(&home) { + Ok(Some(cache)) => { + build_marketplace_result(&home, &cache.catalog, &url, cache.fetched_at, true) + } + Ok(None) => Err(format!( + "failed to fetch plugin marketplace: {fetch_err}; no local cache available" + )), + Err(cache_err) => Err(format!( + "failed to fetch plugin marketplace: {fetch_err}; local cache could not be read: {cache_err}" + )), + }, + } +} + +#[tauri::command] +pub fn list_installed_plugins(home_override: Option) -> Result, String> { + let home = crate::dashboard::resolve_kimi_home(home_override); + let file = read_installed(&home)?; + let mut out: Vec = + file.plugins.iter().map(|record| installed_info(record, &home)).collect(); + out.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(out) +} + +#[tauri::command] +pub async fn install_plugin( + home_override: Option, + source: String, + expected_id: Option, +) -> Result { + let home = crate::dashboard::resolve_kimi_home(home_override); + if let Some(expected) = expected_id.as_deref() { + if let Some(entry) = find_catalog_capability_entry(&home, expected)? { + return Err(format!( + "plugin {expected:?} is a built-in capability plugin (capabilityId: {}) and cannot be installed from the marketplace; use the official channel", + entry.capability_id.unwrap_or_default() + )); + } + } + let original_source = source.trim().to_string(); + match resolve_install_source(&original_source)? { + ResolvedSource::LocalPath(path) => Err(format!( + "installing plugins from local paths is not supported ({path}); provide a zip URL or a GitHub repository URL" + )), + ResolvedSource::ZipUrl(url) => { + let tmp = download_to_temp_file(&url).await?; + let result = install_from_zip( + &home, + &tmp, + expected_id.as_deref(), + &original_source, + "zip-url", + None, + ); + let _ = fs::remove_file(&tmp); + result + } + ResolvedSource::Github { + owner, + repo, + r#ref, + } => { + let (zip_url, resolved_ref) = resolve_github_zip_url(&owner, &repo, &r#ref).await?; + let tmp = download_to_temp_file(&zip_url).await?; + // installedSha needs the GitHub Atom feed; left None (documented + // divergence — update detection here uses the catalog version). + let github = Some(GithubMetadata { + owner, + repo, + r#ref: resolved_ref, + installed_sha: None, + }); + let result = install_from_zip( + &home, + &tmp, + expected_id.as_deref(), + &original_source, + "github", + github, + ); + let _ = fs::remove_file(&tmp); + result + } + } +} + +#[tauri::command] +pub fn set_plugin_enabled( + home_override: Option, + id: String, + enabled: bool, +) -> Result<(), String> { + let home = crate::dashboard::resolve_kimi_home(home_override); + let _guard = INSTALLED_LOCK.lock().unwrap(); + let mut file = read_installed(&home)?; + let record = file + .plugins + .iter_mut() + .find(|r| r.id == id) + .ok_or_else(|| format!("plugin {id:?} is not installed"))?; + if record.enabled == enabled { + // No-op, mirrors kimi-code `setEnabled`. + return Ok(()); + } + record.enabled = enabled; + record.updated_at = Some(now_iso()); + write_installed(&home, &file) +} + +#[tauri::command] +pub fn remove_plugin(home_override: Option, id: String) -> Result<(), String> { + let home = crate::dashboard::resolve_kimi_home(home_override); + let _guard = INSTALLED_LOCK.lock().unwrap(); + let mut file = read_installed(&home)?; + let before = file.plugins.len(); + file.plugins.retain(|r| r.id != id); + if file.plugins.len() == before { + return Err(format!("plugin {id:?} is not installed")); + } + write_installed(&home, &file) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Cursor, Write}; + use tempfile::TempDir; + + fn sandbox() -> TempDir { + TempDir::new().expect("tempdir") + } + + fn write(home: &Path, rel: &str, content: &str) { + let path = home.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + fn read_to_string(home: &Path, rel: &str) -> String { + fs::read_to_string(home.join(rel)).unwrap() + } + + /// A kimi-code store.ts-style installed.json (exact field naming). + const KIMI_INSTALLED_SAMPLE: &str = r#"{ + "version": 1, + "plugins": [ + { + "id": "my-plugin", + "root": "C:/Users/x/.kimi-code/plugins/managed/my-plugin", + "source": "github", + "enabled": true, + "installedAt": "2026-08-01T10:00:00.000Z", + "updatedAt": "2026-08-02T10:00:00.000Z", + "originalSource": "https://github.com/owner/repo", + "capabilities": { "mcpServers": { "demo-server": { "enabled": true } } }, + "github": { "owner": "owner", "repo": "repo", "ref": { "kind": "tag", "value": "v1.0.0" } } + } + ] +}"#; + + #[test] + fn installed_json_parses_kimi_code_format() { + let home = sandbox(); + write(home.path(), INSTALLED_REL, KIMI_INSTALLED_SAMPLE); + let file = read_installed(home.path()).unwrap(); + assert_eq!(file.version, 1); + assert_eq!(file.plugins.len(), 1); + let p = &file.plugins[0]; + assert_eq!(p.id, "my-plugin"); + assert_eq!(p.root, "C:/Users/x/.kimi-code/plugins/managed/my-plugin"); + assert_eq!(p.source, "github"); + assert!(p.enabled); + assert_eq!(p.installed_at, "2026-08-01T10:00:00.000Z"); + assert_eq!(p.updated_at.as_deref(), Some("2026-08-02T10:00:00.000Z")); + assert_eq!( + p.original_source.as_deref(), + Some("https://github.com/owner/repo") + ); + let caps = p.capabilities.as_ref().unwrap(); + assert_eq!( + caps["mcpServers"]["demo-server"]["enabled"], + serde_json::json!(true) + ); + let gh = p.github.as_ref().unwrap(); + assert_eq!(gh.owner, "owner"); + assert_eq!(gh.repo, "repo"); + assert_eq!(gh.r#ref.kind, "tag"); + assert_eq!(gh.r#ref.value, "v1.0.0"); + assert!(gh.installed_sha.is_none()); + } + + #[test] + fn installed_json_serializes_exact_kimi_shape() { + let home = sandbox(); + let record = InstalledRecord { + id: "my-plugin".into(), + root: "C:/Users/x/.kimi-code/plugins/managed/my-plugin".into(), + source: "github".into(), + enabled: true, + installed_at: "2026-08-01T10:00:00.000Z".into(), + updated_at: Some("2026-08-02T10:00:00.000Z".into()), + original_source: Some("https://github.com/owner/repo".into()), + capabilities: Some(serde_json::json!({ "mcpServers": { "s": { "enabled": true } } })), + github: Some(GithubMetadata { + owner: "owner".into(), + repo: "repo".into(), + r#ref: GithubRef { kind: "tag".into(), value: "v1.0.0".into() }, + installed_sha: None, + }), + extra: serde_json::Map::new(), + }; + write_installed( + home.path(), + &InstalledFile { + version: 1, + plugins: vec![record], + extra: serde_json::Map::new(), + }, + ) + .unwrap(); + let text = read_to_string(home.path(), INSTALLED_REL); + let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(parsed["version"], serde_json::json!(1)); + assert!(parsed["plugins"].is_array()); + let p = &parsed["plugins"][0]; + // camelCase keys, optional fields omitted (installedSha absent). + assert!(p.get("installedAt").is_some()); + assert!(p.get("updatedAt").is_some()); + assert!(p.get("originalSource").is_some()); + assert!(p.get("capabilities").is_some()); + assert!(p.get("github").is_some()); + assert!(p.get("installedSha").is_none()); + assert_eq!(p["github"]["ref"]["kind"], "tag"); + // Re-parse: the written file is exactly what kimi-code reads back. + let file = read_installed(home.path()).unwrap(); + assert_eq!(file.plugins[0].id, "my-plugin"); + assert_eq!(file.plugins[0].github.as_ref().unwrap().r#ref.value, "v1.0.0"); + } + + #[test] + fn installed_json_roundtrip_preserves_unknown_fields() { + let home = sandbox(); + let raw = r#"{ + "version": 1, + "futureField": { "keep": true }, + "plugins": [ + { + "id": "p1", + "root": "C:/r", + "source": "zip-url", + "enabled": false, + "installedAt": "2026-01-01T00:00:00.000Z", + "futureRecordField": "preserved" + } + ] + }"#; + write(home.path(), INSTALLED_REL, raw); + let mut file = read_installed(home.path()).unwrap(); + file.plugins[0].enabled = true; + write_installed(home.path(), &file).unwrap(); + let text = read_to_string(home.path(), INSTALLED_REL); + let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(parsed["futureField"]["keep"], serde_json::json!(true)); + assert_eq!(parsed["plugins"][0]["futureRecordField"], "preserved"); + assert_eq!(parsed["plugins"][0]["enabled"], serde_json::json!(true)); + } + + #[test] + fn installed_json_exact_string_matches_kimi_format() { + // The full serialized document our write_installed produces for a + // GitHub-installed plugin — byte-for-byte the shape kimi-code store.ts + // writes (2-space indent, camelCase, optional fields omitted). + let record = InstalledRecord { + id: "my-plugin".into(), + root: "C:/Users/x/.kimi-code/plugins/managed/my-plugin".into(), + source: "github".into(), + enabled: true, + installed_at: "2026-08-01T10:00:00.000Z".into(), + updated_at: Some("2026-08-02T10:00:00.000Z".into()), + original_source: Some("https://github.com/owner/repo".into()), + capabilities: Some(serde_json::json!({ "mcpServers": { "demo-server": { "enabled": true } } })), + github: Some(GithubMetadata { + owner: "owner".into(), + repo: "repo".into(), + r#ref: GithubRef { kind: "tag".into(), value: "v1.0.0".into() }, + installed_sha: None, + }), + extra: serde_json::Map::new(), + }; + let file = InstalledFile { + version: 1, + plugins: vec![record], + extra: serde_json::Map::new(), + }; + let expected = r#"{ + "version": 1, + "plugins": [ + { + "id": "my-plugin", + "root": "C:/Users/x/.kimi-code/plugins/managed/my-plugin", + "source": "github", + "enabled": true, + "installedAt": "2026-08-01T10:00:00.000Z", + "updatedAt": "2026-08-02T10:00:00.000Z", + "originalSource": "https://github.com/owner/repo", + "capabilities": { + "mcpServers": { + "demo-server": { + "enabled": true + } + } + }, + "github": { + "owner": "owner", + "repo": "repo", + "ref": { + "kind": "tag", + "value": "v1.0.0" + } + } + } + ] +}"#; + assert_eq!(serde_json::to_string_pretty(&file).unwrap(), expected); + } + + #[test] + fn installed_json_missing_file_is_empty() { + let home = sandbox(); + let file = read_installed(home.path()).unwrap(); + assert_eq!(file.version, 1); + assert!(file.plugins.is_empty()); + } + + #[test] + fn catalog_parses_field_aliases_and_defaults() { + let catalog = serde_json::json!({ + "version": "1", + "plugins": [ + { + "id": "a", + "name": "Alias Name", + "url": "./official/a.zip", + "shortDescription": "legacy desc", + "keywords": ["k1", "", " "] + }, + { + "id": "b", + "displayName": "Modern", + "source": "./curated/b.zip", + "tier": "official", + "version": "2.0.0", + "capabilityId": "cap-x" + } + ] + }); + let url = "https://code.kimi.com/kimi-code/plugins/marketplace.json"; + let entries = parse_catalog_from_value(&catalog, url).unwrap(); + assert_eq!(entries.len(), 2); + let a = &entries[0]; + assert_eq!(a.id, "a"); + assert_eq!(a.display_name, "Alias Name"); // name → displayName + assert_eq!(a.description.as_deref(), Some("legacy desc")); // shortDescription → description + assert_eq!(a.keywords, vec!["k1"]); // blank keywords dropped + assert_eq!(a.tier, "curated"); // tier missing → default + assert!(a.version.is_none()); + assert_eq!(a.source, "https://code.kimi.com/kimi-code/plugins/official/a.zip"); + assert!(a.capability_id.is_none()); + let b = &entries[1]; + assert_eq!(b.tier, "official"); + assert_eq!(b.version.as_deref(), Some("2.0.0")); + assert_eq!(b.capability_id.as_deref(), Some("cap-x")); // passed through, not dropped + assert_eq!(b.source, "https://code.kimi.com/kimi-code/plugins/curated/b.zip"); + } + + #[test] + fn catalog_requires_source() { + let catalog = serde_json::json!({ "plugins": [{ "id": "a", "name": "A" }] }); + let err = parse_catalog_from_value(&catalog, "https://x/y.json").unwrap_err(); + assert!(err.contains("must define \"source\""), "got: {err}"); + } + + #[test] + fn catalog_requires_plugins_array() { + let err = parse_catalog_from_value(&serde_json::json!({ "foo": 1 }), "https://x/y.json").unwrap_err(); + assert!(err.contains("plugins"), "got: {err}"); + } + + #[test] + fn catalog_relative_source_resolves_against_catalog_url() { + assert_eq!( + resolve_relative_url("https://code.kimi.com/kimi-code/plugins/marketplace.json", "./official/x.zip"), + "https://code.kimi.com/kimi-code/plugins/official/x.zip" + ); + assert_eq!( + resolve_relative_url("https://code.kimi.com/kimi-code/plugins/marketplace.json", "../other.zip"), + "https://code.kimi.com/kimi-code/other.zip" + ); + assert_eq!( + resolve_relative_url("https://code.kimi.com/kimi-code/plugins/marketplace.json", "/abs/x.zip"), + "https://code.kimi.com/abs/x.zip" + ); + assert_eq!( + resolve_relative_url("https://code.kimi.com/kimi-code/plugins/marketplace.json", "//cdn.example.com/x.zip"), + "https://cdn.example.com/x.zip" + ); + // Local catalog: relative to its directory (platform-native separators). + let expected = PathBuf::from("C:/somewhere").join("official/x.zip"); + assert_eq!( + PathBuf::from(resolve_entry_source("./official/x.zip", "C:/somewhere/marketplace.json")), + expected + ); + } + + #[test] + fn github_url_recognition_four_forms() { + let bare = resolve_install_source("https://github.com/owner/repo").unwrap(); + match bare { + ResolvedSource::Github { owner, repo, r#ref } => { + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + assert!(r#ref.is_none()); + } + other => panic!("expected github, got {other:?}"), + } + + let tree_branch = resolve_install_source("https://github.com/o/r/tree/main").unwrap(); + match tree_branch { + ResolvedSource::Github { r#ref: Some(r), .. } => { + assert_eq!(r.kind, "branch"); + assert_eq!(r.value, "main"); + } + other => panic!("expected github tree ref, got {other:?}"), + } + + let tree_sha = resolve_install_source("https://github.com/o/r/tree/abcdef1").unwrap(); + match tree_sha { + ResolvedSource::Github { r#ref: Some(r), .. } => assert_eq!(r.kind, "sha"), + other => panic!("expected sha ref, got {other:?}"), + } + + let tag = resolve_install_source("https://github.com/o/r/releases/tag/v1.2.3").unwrap(); + match tag { + ResolvedSource::Github { r#ref: Some(r), .. } => { + assert_eq!(r.kind, "tag"); + assert_eq!(r.value, "v1.2.3"); + } + other => panic!("expected tag ref, got {other:?}"), + } + + let commit = resolve_install_source("https://github.com/o/r/commit/0123456789abcdef0123456789abcdef01234567").unwrap(); + match commit { + ResolvedSource::Github { r#ref: Some(r), .. } => { + assert_eq!(r.kind, "sha"); + assert_eq!(r.value.len(), 40); + } + other => panic!("expected commit ref, got {other:?}"), + } + + // www + .git normalization, and the codeload URL shape for tags. + let www = resolve_install_source("https://www.github.com/o/r.git").unwrap(); + match www { + ResolvedSource::Github { repo, .. } => assert_eq!(repo, "r"), + other => panic!("expected repo stripped of .git, got {other:?}"), + } + let r = GithubRef { kind: "tag".into(), value: "v1.2.3".into() }; + assert_eq!( + github_zip_url("o", "r", &r), + "https://codeload.github.com/o/r/zip/refs/tags/v1.2.3" + ); + let r = GithubRef { kind: "branch".into(), value: "feature/x".into() }; + assert_eq!( + github_zip_url("o", "r", &r), + "https://codeload.github.com/o/r/zip/feature/x" + ); + + // Plain http(s) that is not GitHub → zip URL. + match resolve_install_source("https://example.com/a.zip").unwrap() { + ResolvedSource::ZipUrl(u) => assert_eq!(u, "https://example.com/a.zip"), + other => panic!("expected zip url, got {other:?}"), + } + // Non-URL, non-absolute → error. + assert!(resolve_install_source("relative/path").is_err()); + } + + /// Build an in-memory zip with the given (name, content) entries. + fn build_zip(entries: &[(&str, &[u8])]) -> Vec { + let mut buf = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut buf); + let options = zip::write::SimpleFileOptions::default(); + for (name, content) in entries { + writer.start_file(*name, options).unwrap(); + writer.write_all(content).unwrap(); + } + writer.finish().unwrap(); + } + buf.into_inner() + } + + fn zip_to_file(zip_bytes: &[u8]) -> TempDir { + let dir = sandbox(); + fs::write(dir.path().join("pkg.zip"), zip_bytes).unwrap(); + dir + } + + #[test] + fn zip_extraction_rejects_path_traversal() { + // "../evil" — must be refused before anything is written outside. + let cases: &[&str] = &[ + "../evil.txt", + "a/../../evil.txt", + "\\..\\evil.txt", + "/absolute.txt", + "C:/drive.txt", + "C:\\drive.txt", + ]; + for name in cases { + let dir = zip_to_file(&build_zip(&[(name, b"x")])); + let dest = dir.path().join("out"); + let err = extract_zip(&dir.path().join("pkg.zip"), &dest).unwrap_err(); + assert!(err.contains("path traversal"), "entry {name:?} → {err}"); + // Nothing may have been written outside `out`. + assert!(!dir.path().join("evil.txt").exists()); + assert!(!dir.path().join("drive.txt").exists()); + } + } + + #[test] + fn zip_extraction_writes_normal_layout() { + let dir = zip_to_file(&build_zip(&[ + ("kimi.plugin.json", br#"{"name":"ok","version":"1.0.0"}"#), + ("skills/readme.md", b"hi"), + ])); + let dest = dir.path().join("out"); + extract_zip(&dir.path().join("pkg.zip"), &dest).unwrap(); + assert!(dest.join("kimi.plugin.json").is_file()); + assert_eq!(fs::read_to_string(dest.join("skills/readme.md")).unwrap(), "hi"); + } + + #[test] + fn zip_extraction_skips_dot_components() { + let dir = zip_to_file(&build_zip(&[("a/./b.txt", b"x")])); + let dest = dir.path().join("out"); + extract_zip(&dir.path().join("pkg.zip"), &dest).unwrap(); + assert!(dest.join("a/b.txt").is_file()); + } + + #[test] + fn plugin_name_regex_validation() { + for ok in ["my-plugin", "my_plugin", "a", "0start", "x".repeat(64).as_str()] { + assert!(validate_plugin_name(ok).is_ok(), "{ok:?} should be valid"); + } + for bad in ["MyPlugin", "-lead", "_lead", "", "x".repeat(65).as_str(), "a b", "插件"] { + assert!(validate_plugin_name(bad).is_err(), "{bad:?} should be invalid"); + } + } + + #[test] + fn semver_compare_behaviour() { + assert_eq!(compare_simple_semver("1.2.4", "1.2.3"), Some(Ordering::Greater)); + assert_eq!(compare_simple_semver("1.2.3", "1.2.3"), Some(Ordering::Equal)); + assert_eq!(compare_simple_semver("1.2.3", "1.2.4"), Some(Ordering::Less)); + assert_eq!(compare_simple_semver("v1.0.1", "1.0.0"), Some(Ordering::Greater)); + assert_eq!(compare_simple_semver("2.0", "2.0.1"), Some(Ordering::Less)); + assert_eq!(compare_simple_semver("1.2.3-beta.1", "1.2.3"), Some(Ordering::Equal)); + assert_eq!(compare_simple_semver("garbage", "1.0.0"), None); + assert_eq!(compare_simple_semver("1.0.0", "latest"), None); + } + + #[test] + fn set_enabled_updates_file_and_updated_at() { + let home = sandbox(); + write(home.path(), INSTALLED_REL, KIMI_INSTALLED_SAMPLE); + set_plugin_enabled(Some(home.path().to_string_lossy().to_string()), "my-plugin".into(), false).unwrap(); + let text = read_to_string(home.path(), INSTALLED_REL); + let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(parsed["plugins"][0]["enabled"], serde_json::json!(false)); + let updated = parsed["plugins"][0]["updatedAt"].as_str().unwrap(); + assert_ne!(updated, "2026-08-02T10:00:00.000Z"); + assert!(updated.ends_with('Z')); + // Toggling again is a no-op that keeps the previous updatedAt. + set_plugin_enabled(Some(home.path().to_string_lossy().to_string()), "my-plugin".into(), false).unwrap(); + let text2 = read_to_string(home.path(), INSTALLED_REL); + assert_eq!(text, text2); + // Unknown id → error. + assert!(set_plugin_enabled(Some(home.path().to_string_lossy().to_string()), "nope".into(), true).is_err()); + } + + #[test] + fn remove_plugin_deletes_record_keeps_files() { + let home = sandbox(); + write(home.path(), INSTALLED_REL, KIMI_INSTALLED_SAMPLE); + let managed = home.path().join("plugins/managed/my-plugin"); + write(home.path(), "plugins/managed/my-plugin/kimi.plugin.json", r#"{"name":"my-plugin"}"#); + remove_plugin(Some(home.path().to_string_lossy().to_string()), "my-plugin".into()).unwrap(); + let text = read_to_string(home.path(), INSTALLED_REL); + let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(parsed["plugins"].as_array().unwrap().len(), 0); + assert_eq!(parsed["version"], serde_json::json!(1)); + // Managed files stay on disk. + assert!(managed.join("kimi.plugin.json").is_file()); + assert!(remove_plugin(Some(home.path().to_string_lossy().to_string()), "my-plugin".into()).is_err()); + } + + fn manifest_zip(name: &str, version: &str, subdir: bool) -> Vec { + let manifest = format!(r#"{{"name":"{name}","version":"{version}","description":"t"}}"#); + if subdir { + build_zip(&[("pkg/", b""), ("pkg/kimi.plugin.json", manifest.as_bytes())]) + } else { + build_zip(&[("kimi.plugin.json", manifest.as_bytes())]) + } + } + + #[test] + fn install_pipeline_publishes_and_records() { + let home = sandbox(); + let zip = zip_to_file(&manifest_zip("test-plugin", "1.2.3", false)); + let info = install_from_zip( + home.path(), + &zip.path().join("pkg.zip"), + None, + "https://example.com/test-plugin.zip", + "zip-url", + None, + ) + .unwrap(); + assert_eq!(info.id, "test-plugin"); + assert_eq!(info.version.as_deref(), Some("1.2.3")); + assert_eq!(info.source, "zip-url"); + assert!(info.enabled); + let root = PathBuf::from(&info.root); + assert_eq!(root, home.path().join("plugins/managed/test-plugin")); + assert!(root.join("kimi.plugin.json").is_file()); + // No staging leftovers. + let entries: Vec<_> = fs::read_dir(home.path().join("plugins/managed")).unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert_eq!(entries, vec!["test-plugin".to_string()]); + // Record written in kimi-code format. + let text = read_to_string(home.path(), INSTALLED_REL); + let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(parsed["version"], serde_json::json!(1)); + assert_eq!(parsed["plugins"][0]["id"], "test-plugin"); + assert_eq!(parsed["plugins"][0]["source"], "zip-url"); + assert_eq!( + parsed["plugins"][0]["originalSource"], + "https://example.com/test-plugin.zip" + ); + assert_eq!(parsed["plugins"][0]["enabled"], serde_json::json!(true)); + assert!(parsed["plugins"][0]["installedAt"].as_str().unwrap().ends_with('Z')); + } + + #[test] + fn install_pipeline_detects_single_subdir_root() { + let home = sandbox(); + let zip = zip_to_file(&manifest_zip("sub-plugin", "0.1.0", true)); + let info = install_from_zip(home.path(), &zip.path().join("pkg.zip"), None, "https://x/sub.zip", "zip-url", None) + .unwrap(); + let root = PathBuf::from(&info.root); + assert!(root.join("kimi.plugin.json").is_file()); + assert_eq!(root.file_name().unwrap(), "sub-plugin"); + } + + #[test] + fn install_expected_id_mismatch_rejected() { + let home = sandbox(); + let zip = zip_to_file(&manifest_zip("actual-name", "1.0.0", false)); + let err = install_from_zip( + home.path(), + &zip.path().join("pkg.zip"), + Some("expected-name"), + "https://x/a.zip", + "zip-url", + None, + ) + .unwrap_err(); + assert!(err.contains("does not match"), "got: {err}"); + // Nothing recorded, nothing published. + assert!(!home.path().join(INSTALLED_REL).exists()); + } + + #[test] + fn install_updates_existing_record_preserving_enabled() { + let home = sandbox(); + // Pre-installed, disabled, with a fixed installedAt. + let pre = serde_json::json!({ + "version": 1, + "plugins": [{ + "id": "test-plugin", + "root": home.path().join("plugins/managed/test-plugin").to_string_lossy(), + "source": "zip-url", + "enabled": false, + "installedAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z", + "originalSource": "https://old.example.com/x.zip" + }] + }); + write(home.path(), INSTALLED_REL, &serde_json::to_string_pretty(&pre).unwrap()); + let zip = zip_to_file(&manifest_zip("test-plugin", "2.0.0", false)); + let info = install_from_zip( + home.path(), + &zip.path().join("pkg.zip"), + None, + "https://new.example.com/y.zip", + "zip-url", + None, + ) + .unwrap(); + assert!(!info.enabled, "reinstall keeps the previous enabled state"); + let text = read_to_string(home.path(), INSTALLED_REL); + let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); + let p = &parsed["plugins"][0]; + assert_eq!(p["installedAt"], "2026-01-01T00:00:00.000Z"); // preserved + assert_eq!(p["originalSource"], "https://new.example.com/y.zip"); // refreshed + assert_ne!(p["updatedAt"], "2026-01-01T00:00:00.000Z"); // refreshed + assert_eq!(info.version.as_deref(), Some("2.0.0")); + } + + #[test] + fn install_pipeline_rolls_back_on_record_write_failure() { + let home = sandbox(); + // A stale managed root forces the "move aside + restore" publish path. + let stale_root = home.path().join("plugins/managed/test-plugin"); + fs::create_dir_all(&stale_root).unwrap(); + fs::write(stale_root.join("kimi.plugin.json"), "{}").unwrap(); + // Force the installed.json write to fail: the tmp path is a directory. + fs::create_dir_all(home.path().join("plugins/installed.json.tmp")).unwrap(); + + let zip = zip_to_file(&manifest_zip("test-plugin", "1.0.0", false)); + let err = install_from_zip( + home.path(), + &zip.path().join("pkg.zip"), + None, + "https://x/a.zip", + "zip-url", + None, + ) + .unwrap_err(); + assert!(err.contains("failed"), "got: {err}"); + // Rollback: the old managed copy is back with its original content. + assert_eq!( + fs::read_to_string(stale_root.join("kimi.plugin.json")).unwrap(), + "{}" + ); + // No staging leftovers. + let entries: Vec<_> = fs::read_dir(home.path().join("plugins/managed")) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert_eq!(entries, vec!["test-plugin".to_string()]); + } + + #[test] + fn install_github_records_metadata_without_sha() { + let home = sandbox(); + let zip = zip_to_file(&manifest_zip("gh-plugin", "1.0.0", false)); + let github = Some(GithubMetadata { + owner: "o".into(), + repo: "r".into(), + r#ref: GithubRef { kind: "branch".into(), value: "HEAD".into() }, + installed_sha: None, + }); + let info = install_from_zip(home.path(), &zip.path().join("pkg.zip"), None, "https://github.com/o/r", "github", github) + .unwrap(); + assert_eq!(info.source, "github"); + let text = read_to_string(home.path(), INSTALLED_REL); + let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); + let gh = &parsed["plugins"][0]["github"]; + assert_eq!(gh["owner"], "o"); + assert_eq!(gh["repo"], "r"); + assert_eq!(gh["ref"]["kind"], "branch"); + assert!(gh.get("installedSha").is_none(), "installedSha stays omitted"); + } + + #[test] + fn marketplace_merge_reports_install_and_update() { + let home = sandbox(); + // Installed plugin at v1.0.0, catalog lists v1.2.0. + write(home.path(), INSTALLED_REL, KIMI_INSTALLED_SAMPLE); + let real_root = home.path().join("plugins/managed/my-plugin"); + write(home.path(), "plugins/managed/my-plugin/kimi.plugin.json", r#"{"name":"my-plugin","version":"1.0.0"}"#); + // Fix the record's root to the sandbox path. + let mut file = read_installed(home.path()).unwrap(); + file.plugins[0].root = real_root.to_string_lossy().to_string(); + write_installed(home.path(), &file).unwrap(); + + let catalog = serde_json::json!({ + "plugins": [ + { "id": "my-plugin", "name": "My Plugin", "source": "./official/my-plugin.zip", "version": "1.2.0" }, + { "id": "not-installed", "name": "Fresh", "source": "./curated/fresh.zip", "version": "3.0.0" } + ] + }); + let result = build_marketplace_result( + home.path(), + &catalog, + "https://code.kimi.com/kimi-code/plugins/marketplace.json", + "2026-08-01T00:00:00.000Z".into(), + false, + ) + .unwrap(); + assert!(!result.from_cache); + assert_eq!(result.entries.len(), 2); + let mine = &result.entries[0]; + assert!(mine.update_available, "catalog 1.2.0 > installed 1.0.0"); + let installed = mine.installed.as_ref().unwrap(); + assert!(installed.is_marketplace); + assert_eq!(installed.version.as_deref(), Some("1.0.0")); + let fresh = &result.entries[1]; + assert!(!fresh.update_available); + assert!(fresh.installed.is_none()); + } + + #[test] + fn marketplace_cache_fallback_and_is_marketplace() { + let home = sandbox(); + // Seed the cache as if a previous fetch had succeeded. + let catalog = serde_json::json!({ + "plugins": [{ "id": "cached-plug", "name": "Cached", "source": "./official/cached.zip" }] + }); + write_catalog_cache(home.path(), "2026-07-01T00:00:00.000Z", &catalog).unwrap(); + // list_installed_plugins marks matching installs as marketplace. + let mut file = InstalledFile::default(); + file.plugins.push(InstalledRecord { + id: "cached-plug".into(), + root: home.path().join("plugins/managed/cached-plug").to_string_lossy().to_string(), + source: "zip-url".into(), + enabled: true, + installed_at: "2026-07-02T00:00:00.000Z".into(), + updated_at: None, + original_source: Some("https://code.kimi.com/kimi-code/plugins/official/cached.zip".into()), + capabilities: None, + github: None, + extra: serde_json::Map::new(), + }); + write_installed(home.path(), &file).unwrap(); + let list = list_installed_plugins(Some(home.path().to_string_lossy().to_string())).unwrap(); + assert_eq!(list.len(), 1); + assert!(list[0].is_marketplace); + assert_eq!(list[0].source, "zip-url"); + // A non-catalog install is not marketplace. + file.plugins.push(InstalledRecord { + id: "local-only".into(), + root: "C:/nope".into(), + source: "zip-url".into(), + enabled: true, + installed_at: "2026-07-02T00:00:00.000Z".into(), + updated_at: None, + original_source: Some("https://unknown.example.com/x.zip".into()), + capabilities: None, + github: None, + extra: serde_json::Map::new(), + }); + write_installed(home.path(), &file).unwrap(); + let list = list_installed_plugins(Some(home.path().to_string_lossy().to_string())).unwrap(); + let local_only = list.iter().find(|i| i.id == "local-only").unwrap(); + assert!(!local_only.is_marketplace); + } + + #[test] + fn capability_entry_backstop_blocks_install() { + let home = sandbox(); + let catalog = serde_json::json!({ + "plugins": [{ "id": "builtin-x", "name": "X", "source": "./official/x.zip", "capabilityId": "cap-123" }] + }); + write_catalog_cache(home.path(), "2026-07-01T00:00:00.000Z", &catalog).unwrap(); + let found = find_catalog_capability_entry(home.path(), "builtin-x").unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().capability_id.as_deref(), Some("cap-123")); + // Plain entry → not blocked. + let catalog2 = serde_json::json!({ "plugins": [{ "id": "normal", "name": "N", "source": "./o.zip" }] }); + write_catalog_cache(home.path(), "2026-07-01T00:00:00.000Z", &catalog2).unwrap(); + assert!(find_catalog_capability_entry(home.path(), "normal").unwrap().is_none()); + assert!(find_catalog_capability_entry(home.path(), "missing").unwrap().is_none()); + } + + #[test] + fn move_aside_error_explains_os_error_32() { + let locked = std::io::Error::from_raw_os_error(32); + let msg = move_aside_error(&locked); + assert!(msg.contains("os error 32"), "msg: {msg}"); + assert!(msg.contains("Close kimi-code"), "msg: {msg}"); + // Other I/O errors keep the original format. + let other = std::io::Error::from_raw_os_error(5); + assert!(move_aside_error(&other).starts_with("failed to move existing plugin aside:")); + } +} diff --git a/src-tauri/src/services/balance.rs b/src-tauri/src/services/balance.rs index a39fa66..8c8ed11 100644 --- a/src-tauri/src/services/balance.rs +++ b/src-tauri/src/services/balance.rs @@ -37,16 +37,35 @@ pub(crate) enum Fetched { /// /// 先 `bytes()` 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 /// 后解析失败才是确定性。reqwest 的 `.json()` 把读体错误也包成 decode,无法区分。 +/// +/// 默认不带 User-Agent;需要自定义 UA 的供应商(如 opencode.ai 有 Cloudflare +/// 1010 拦截,必须带浏览器 UA)走 [`get_json_with_ua`]。 pub(crate) async fn get_json( url: &str, api_key: &str, auth: AuthStyle, timeout: Duration, ) -> Result { - let client = reqwest::Client::builder() - .timeout(timeout) - .build() - .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + get_json_with_ua(url, api_key, auth, timeout, None).await +} + +/// [`get_json`] 的 UA 变体:`user_agent` 为 `Some` 时设到 client 上。 +/// 仅新增链路需要,现有调用方不受影响。 +pub(crate) async fn get_json_with_ua( + url: &str, + api_key: &str, + auth: AuthStyle, + timeout: Duration, + user_agent: Option<&str>, +) -> Result { + let mut builder = reqwest::Client::builder().timeout(timeout); + if let Some(ua) = user_agent { + builder = builder.user_agent(ua); + } + let client = match builder.build() { + Ok(c) => c, + Err(e) => return Err(format!("Failed to build HTTP client: {e}")), + }; // 注意:api_key 只允许进请求头,严禁拼进 URL / 日志 / 错误信息。 let req = client.get(url).header("Accept", "application/json"); diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 9b63513..47f956b 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -3,23 +3,24 @@ //! Token Plan 套餐额度查询服务 //! -//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的套餐额度查询。 +//! 支持 Kimi For Coding、智谱 GLM、MiniMax、OpenCode Go 的套餐额度查询。 //! cc-switch 的 SubscriptionQuota/tiers 结构在此展平为 `Vec`: //! 每个窗口(tier)一条 UsageData,`plan_name` = tier 名("five_hour" / -//! "weekly_limit"),`used` = 已用百分比(0-100),`total` = 100, +//! "weekly_limit" / "monthly_limit"),`used` = 已用百分比(0-100),`total` = 100, //! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。 //! //! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。 -use super::balance::{get_json, AuthStyle, Fetched}; +use super::balance::{get_json, get_json_with_ua, AuthStyle, Fetched}; use super::usage_types::{UsageData, UsageResult}; use std::time::Duration; -// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax 及未来新增) -// 都只用这两个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖此约定 -// 做本地化映射——新增 tier id 时必须同步加映射。 +// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax/OpenCode Go 及 +// 未来新增)都只用这三个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖 +// 此约定做本地化映射——新增 tier id 时必须同步加映射。 const TIER_FIVE_HOUR: &str = "five_hour"; const TIER_WEEKLY_LIMIT: &str = "weekly_limit"; +const TIER_MONTHLY_LIMIT: &str = "monthly_limit"; /// 套餐条目的统一构造:按百分比表示用量。 fn percent_tier(name: &str, used_percent: f64, resets_at: Option) -> UsageData { @@ -315,6 +316,81 @@ fn parse_minimax(body: &serde_json::Value) -> Result, UsageResult Ok(tiers) } +// ── OpenCode Go ───────────────────────────────────────────── +// GET https://opencode.ai/zen/go/v1/usage +// Response: { usage: { rolling: { status, percent, resetsAt }, +// weekly: { status, percent, resetsAt }, +// monthly: { status, percent, resetsAt } } } +// percent = 已用百分比(与 percent_tier 语义一致);status 仅作展示参考,缺失容忍; +// resetsAt 为 ISO 8601 字符串。 +// +// 坑位:opencode.ai 有 Cloudflare 1010 拦截——reqwest 默认不带 User-Agent 的 +// 裸请求会被 403(error code: 1010)。必须显式设置浏览器 UA。 + +const BROWSER_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \ +AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + +pub async fn query_opencode_go( + api_key: &str, + timeout: Duration, +) -> Result { + match get_json_with_ua( + "https://opencode.ai/zen/go/v1/usage", + api_key, + AuthStyle::Bearer, + timeout, + Some(BROWSER_USER_AGENT), + ) + .await? + { + Fetched::Body(body) => { + let tiers = parse_opencode_go(&body); + if tiers.is_empty() { + // 响应里没有可解析的用量窗口(usage 缺失或字段变了)。 + // 把原始响应(仅用量数字,无密钥)透出,方便对照接口结构修复。 + return Ok(opencode_go_empty_failure(&body)); + } + Ok(UsageResult::ok(tiers)) + } + Fetched::Failed(err) => Ok(err), + } +} + +/// usage 全缺时的确定性失败,附 400 字原始响应预览。抽成纯函数便于离线单测。 +fn opencode_go_empty_failure(body: &serde_json::Value) -> UsageResult { + let preview = serde_json::to_string(body) + .unwrap_or_else(|_| "".into()); + let trimmed: String = preview.chars().take(400).collect(); + UsageResult::failure(format!( + "opencode go usage 响应无套餐数据,原始返回: {trimmed}" + )) +} + +/// 三个窗口(5 小时滚动 / 周 / 月)各解析为一条 `percent_tier`; +/// 缺失或 percent 不可解析的窗口跳过(status 字段可缺)。 +fn parse_opencode_go(body: &serde_json::Value) -> Vec { + let mut tiers = Vec::new(); + let Some(usage) = body.get("usage") else { + return tiers; + }; + let windows = [ + (TIER_FIVE_HOUR, "rolling"), + (TIER_WEEKLY_LIMIT, "weekly"), + (TIER_MONTHLY_LIMIT, "monthly"), + ]; + for (name, key) in windows { + let Some(window) = usage.get(key) else { + continue; + }; + let Some(percent) = window.get("percent").and_then(parse_f64) else { + continue; + }; + let resets_at = window.get("resetsAt").and_then(extract_reset_time); + tiers.push(percent_tier(name, percent, resets_at)); + } + tiers +} + #[cfg(test)] mod tests { use super::*; @@ -429,4 +505,77 @@ mod tests { assert!(!err.success); assert!(err.error.unwrap().contains("invalid key")); } + + #[test] + fn opencode_go_parses_three_windows() { + // 真实 key 实测样例(2026-08):percent 为已用百分比,直接透传 + let body = json!({ + "usage": { + "rolling": { "status": "ok", "percent": 9, "resetsAt": "2026-08-18T06:09:19.735Z" }, + "weekly": { "status": "ok", "percent": 5, "resetsAt": "2026-08-24T00:00:00.735Z" }, + "monthly": { "status": "ok", "percent": 59, "resetsAt": "2026-08-27T03:33:50.735Z" } + } + }); + let tiers = parse_opencode_go(&body); + assert_eq!(tiers.len(), 3); + assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour")); + assert_eq!(tiers[0].used, Some(9.0)); + assert_eq!(tiers[0].total, Some(100.0)); + assert_eq!(tiers[0].remaining, Some(91.0)); + assert_eq!( + tiers[0].resets_at.as_deref(), + Some("2026-08-18T06:09:19.735Z") + ); + assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit")); + assert_eq!(tiers[1].used, Some(5.0)); + assert_eq!(tiers[1].remaining, Some(95.0)); + assert_eq!(tiers[2].plan_name.as_deref(), Some("monthly_limit")); + assert_eq!(tiers[2].used, Some(59.0)); + assert_eq!(tiers[2].remaining, Some(41.0)); + } + + #[test] + fn opencode_go_tolerates_missing_status_field() { + // status 属展示字段,缺失照常解析(percent/resetsAt 都只要求本身存在) + let body = json!({ + "usage": { + "rolling": { "percent": 9, "resetsAt": "2026-08-18T06:09:19.735Z" }, + "weekly": { "percent": 5, "resetsAt": "2026-08-24T00:00:00.735Z" }, + "monthly": { "percent": 59, "resetsAt": "2026-08-27T03:33:50.735Z" } + } + }); + let tiers = parse_opencode_go(&body); + assert_eq!(tiers.len(), 3); + assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit")); + assert_eq!(tiers[2].used, Some(59.0)); + } + + #[test] + fn opencode_go_partial_windows_only_emit_present_ones() { + // 单窗口缺失只出两条;percent 缺失 / 非数字的窗口整窗跳过 + let body = json!({ + "usage": { + "rolling": { "status": "ok", "percent": "9", "resetsAt": "2026-08-18T06:09:19.735Z" }, + "monthly": { "status": "ok", "resetsAt": "2026-08-27T03:33:50.735Z" } + } + }); + let tiers = parse_opencode_go(&body); + assert_eq!(tiers.len(), 1); + // percent 以字符串给出也兼容(parse_f64) + assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour")); + assert_eq!(tiers[0].used, Some(9.0)); + } + + #[test] + fn opencode_go_missing_usage_is_deterministic_failure_with_preview() { + // usage 全缺:确定性失败,附原始响应预览(400 字内,无密钥) + let body = json!({ "error": { "message": "boom" } }); + assert!(parse_opencode_go(&body).is_empty()); + let err = opencode_go_empty_failure(&body); + assert!(!err.success); + assert!(err.data.is_none()); + let msg = err.error.unwrap(); + assert!(msg.contains("原始返回"), "msg: {msg}"); + assert!(msg.contains(r#""message":"boom""#), "msg: {msg}"); + } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 837116a..1fdb46d 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -3,7 +3,7 @@ //! 供应商账单/用量查询统一入口。 //! -//! - [`UsageKind`]:8 种查询类型,字符串形式与前端 / SQLite settings 约定一致 +//! - [`UsageKind`]:11 种查询类型,字符串形式与前端 / SQLite settings 约定一致 //! (如 `"balance:deepseek"`、`"plan:kimi_coding"`)。 //! - [`detect_provider`]:按 base_url host 子串匹配,旧用户无显式配置时自动识别。 //! - [`query_kind`]:按 kind 路由到 balance / coding_plan 的具体实现。 @@ -26,6 +26,7 @@ pub enum UsageKind { PlanKimiCoding, PlanZhipu, PlanMinimax, + PlanOpencodeGo, } impl UsageKind { @@ -42,10 +43,11 @@ impl UsageKind { UsageKind::PlanKimiCoding => "plan:kimi_coding", UsageKind::PlanZhipu => "plan:zhipu", UsageKind::PlanMinimax => "plan:minimax", + UsageKind::PlanOpencodeGo => "plan:opencode_go", } } - pub const ALL: [UsageKind; 10] = [ + pub const ALL: [UsageKind; 11] = [ UsageKind::BalanceDeepseek, UsageKind::BalanceSiliconflow, UsageKind::BalanceOpenrouter, @@ -56,6 +58,7 @@ impl UsageKind { UsageKind::PlanKimiCoding, UsageKind::PlanZhipu, UsageKind::PlanMinimax, + UsageKind::PlanOpencodeGo, ]; } @@ -74,6 +77,7 @@ impl std::str::FromStr for UsageKind { "plan:kimi_coding" => UsageKind::PlanKimiCoding, "plan:zhipu" => UsageKind::PlanZhipu, "plan:minimax" => UsageKind::PlanMinimax, + "plan:opencode_go" => UsageKind::PlanOpencodeGo, _ => return Err(()), }) } @@ -113,6 +117,10 @@ pub fn detect_provider(base_url: &str) -> Vec { if url.contains("api.minimaxi.com") { kinds.push(UsageKind::PlanMinimax); } + // OpenCode Go 套餐:只认 /zen/go 路径;/zen/v1(OpenCode Zen 按量付费)不得命中。 + if url.contains("/zen/go") { + kinds.push(UsageKind::PlanOpencodeGo); + } kinds } @@ -166,6 +174,7 @@ pub async fn query_kind( UsageKind::PlanMinimax => { coding_plan::query_minimax(api_key, !lower.contains("minimax.io"), timeout).await } + UsageKind::PlanOpencodeGo => coding_plan::query_opencode_go(api_key, timeout).await, } } @@ -175,7 +184,7 @@ mod tests { #[test] fn detect_provider_maps_known_hosts() { - let cases: [(&str, UsageKind); 9] = [ + let cases: [(&str, UsageKind); 10] = [ ("https://api.deepseek.com/v1", UsageKind::BalanceDeepseek), ("https://api.siliconflow.cn/v1", UsageKind::BalanceSiliconflow), ("https://openrouter.ai/api/v1", UsageKind::BalanceOpenrouter), @@ -188,6 +197,7 @@ mod tests { UsageKind::PlanZhipu, ), ("https://api.minimaxi.com/v1", UsageKind::PlanMinimax), + ("https://opencode.ai/zen/go/v1", UsageKind::PlanOpencodeGo), ]; for (url, expected) in cases { assert_eq!( @@ -212,6 +222,22 @@ mod tests { assert!(detect_provider("https://api.kimi.com/v1").is_empty()); } + #[test] + fn detect_provider_opencode_go_excludes_payg_zen() { + // /zen/go 命中套餐查询 + assert_eq!( + detect_provider("https://opencode.ai/zen/go/v1"), + vec![UsageKind::PlanOpencodeGo] + ); + // /zen/v1(OpenCode Zen 按量付费)不得命中 + assert!(detect_provider("https://opencode.ai/zen/v1").is_empty()); + // 裸域名也命中(go 套餐 base 无 v1 后缀时) + assert_eq!( + detect_provider("https://opencode.ai/zen/go"), + vec![UsageKind::PlanOpencodeGo] + ); + } + #[test] fn usage_kind_string_roundtrip() { use std::str::FromStr; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 073b15e..1b1ab18 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.7.5", + "version": "0.7.6", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 09bb314..efdb818 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { ProviderEdit } from "./components/ProviderEdit"; import { SubagentSettingsPage } from "./components/SubagentSettingsPage"; import { DashboardPage } from "./components/dashboard/DashboardPage"; import { SessionsPage } from "./components/sessions/SessionsPage"; +import { PluginMarketplacePage } from "./components/plugins/PluginMarketplacePage"; import { SettingsModal } from "./components/SettingsModal"; import { UsageConfigModal } from "./components/UsageConfigModal"; import { PresetPickerModal } from "./components/PresetPickerModal"; @@ -71,7 +72,9 @@ export default function App() { updateConfig, } = useConfig(agent); - const [view, setView] = useState<"list" | "edit" | "subagent" | "dashboard" | "sessions">("list"); + const [view, setView] = useState< + "list" | "edit" | "subagent" | "dashboard" | "sessions" | "plugins" + >("list"); const [editingProvider, setEditingProvider] = useState(""); const [loadTimeout, setLoadTimeout] = useState(false); const [switchMessage, setSwitchMessage] = useState(null); @@ -603,6 +606,14 @@ export default function App() { > {t("sessions")} + {/* 插件市场功能尚未成熟,导航入口暂时隐藏;页面代码保留, + 恢复时把下面的按钮加回即可(view/渲染分支均未移除)。 + + */}
{/* Language */} @@ -650,6 +661,8 @@ export default function App() { ) : view === "sessions" ? ( + ) : view === "plugins" ? ( + ) : view === "subagent" ? ( - {view !== "dashboard" && view !== "sessions" && ( + {view !== "dashboard" && view !== "sessions" && view !== "plugins" && (