From 17762411da986bca4647f7b9844fb95a3918964a Mon Sep 17 00:00:00 2001 From: maguowei Date: Tue, 6 Oct 2026 23:29:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugins):=20=E4=BF=AE=E5=A4=8D=E5=B8=82?= =?UTF-8?q?=E5=9C=BA=E5=88=B7=E6=96=B0=E5=8D=A1=E9=A1=BF=E4=B8=8E=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E8=AF=BB=E5=8F=96=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/user-manual.md | 2 +- docs/user-manual.zh-CN.md | 2 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 2 +- src-tauri/src/claude_cli.rs | 85 +++++- src-tauri/src/lib.rs | 3 +- src-tauri/src/plugins.rs | 69 ++++- src/bindings.ts | 2 + .../profile-editor/BrowseMarketplaceTab.tsx | 59 +++- .../__tests__/BrowseMarketplaceTab.test.tsx | 253 ++++++++++++------ .../__tests__/marketplace-catalog.test.ts | 31 +++ .../__tests__/useMarketplaceCatalog.test.tsx | 16 ++ .../profile-editor/marketplace-catalog.ts | 47 +++- .../profile-editor/plugin-install-counts.ts | 7 +- .../profile-editor/useMarketplaceCatalog.ts | 13 +- src/i18n.ts | 12 + 16 files changed, 484 insertions(+), 120 deletions(-) diff --git a/docs/user-manual.md b/docs/user-manual.md index bdc87f81..65a91fd0 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -121,7 +121,7 @@ The configuration editor on the right is divided into several sections. - **Sandbox**: can be enabled or disabled, with recommended presets to add; complex configurations can switch to JSON mode. - **Hooks**: maintain Claude Code hooks with a summary view, add the built-in garbled-text check preset, and switch complex structures to JSON mode. - **Plugin marketplaces**: maintain `extraKnownMarketplaces` with built-in presets for the official marketplace (`claude-plugins-official`) and community marketplace (`claude-community`, repository `anthropics/claude-plugins-community`), available from both marketplace settings and Browse Marketplaces; each marketplace must fully specify the ID, source, repository or URL, path, package name, and install location. -- **Plugins**: maintain `enabledPlugins`, split into two tabs, "Configured" and "Browse marketplace". Browsing the marketplace currently supports only the `github` source; you can filter by marketplace / enabled status / category / source, and clicking enable immediately syncs it to the configured list. +- **Plugins**: maintain `enabledPlugins`, split into two tabs, "Configured" and "Browse marketplace". Browsing the marketplace currently supports only the `github` source; you can filter by marketplace / enabled status / category / source, and clicking enable immediately syncs it to the configured list. Refresh requests and the install-count refresh each time out after 30 seconds. Failed sources are reported separately; a local cache write failure does not prevent the downloaded list from being displayed. - **Status line**: configure a custom status line command, and optionally enable the default status line preset (non-Windows writes `~/.claude/statusline.sh`; Windows writes `~/.claude/statusline.ps1` and automatically sets the PowerShell invocation command), and also sets a 60-second refresh interval so countdowns such as the prompt cache expiry keep updating while idle; if the target already exists with different content, you are prompted whether to overwrite. - **Final configuration**: preview the final JSON after merging the provider with the configuration; the source JSON mode lets you maintain the entire settings object directly, and the preview automatically adds the schema URL. diff --git a/docs/user-manual.zh-CN.md b/docs/user-manual.zh-CN.md index d82ff505..bf288514 100644 --- a/docs/user-manual.zh-CN.md +++ b/docs/user-manual.zh-CN.md @@ -121,7 +121,7 @@ Skills 对应 `~/.claude/skills//SKILL.md`。启用的 Skill 保存在 `~/.c - **Sandbox**:可启用或关闭,可添加推荐预设;复杂配置可切换 JSON 模式。 - **Hooks**:维护 Claude Code hooks,支持摘要展示,可添加内置乱码检查预设,复杂结构可切 JSON 模式。 - **插件市场**:维护 `extraKnownMarketplaces`,支持官方市场(`claude-plugins-official`)与社区市场(`claude-community`,仓库 `anthropics/claude-plugins-community`)内置预设,可从市场配置或浏览市场一键添加;每个 Marketplace 需完整填写 ID、来源、仓库或 URL、路径、包名、安装位置。 -- **插件**:维护 `enabledPlugins`,分"已配置"和"浏览市场"两个 Tab。浏览市场目前仅支持 `github` 来源,可按 Marketplace / 启用状态 / 类别 / 来源筛选,点击启用会立即同步到已配置列表。 +- **插件**:维护 `enabledPlugins`,分"已配置"和"浏览市场"两个 Tab。浏览市场目前仅支持 `github` 来源,可按 Marketplace / 启用状态 / 类别 / 来源筛选,点击启用会立即同步到已配置列表。列表请求和安装数刷新分别在 30 秒后超时;来源失败会单独提示,本地缓存写入失败仍会展示已下载的列表。 - **状态行**:配置自定义状态行命令,可启用默认状态行预设(非 Windows 写入 `~/.claude/statusline.sh`,Windows 写入 `~/.claude/statusline.ps1` 并自动设置 PowerShell 调用命令),同时写入 60 秒刷新间隔,使缓存有效期等倒计时在空闲时也能更新;目标已存在且内容不同时会提示是否覆盖。 - **最终配置**:预览供应商与配置合并后的最终 JSON;源 JSON 模式可直接维护整个 settings 对象,预览自动加入 schema 地址。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 437b1372..e45d89ef 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5239,6 +5239,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "windows-sys 0.61.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 32253b1f..6b5f6dfe 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,7 +48,7 @@ schemars = { version = "1.2.1", features = ["preserve_order"] } reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] } notify = "8" sqlx = { version = "0.9.0", default-features = false, features = ["sqlite", "runtime-tokio", "tls-rustls"] } -tokio = { version = "1", features = ["sync"] } +tokio = { version = "1", features = ["sync", "process", "time"] } # tauri-specta v2 仍在 RC 阶段(社区主流,Tauri 2 唯一可用端到端类型化方案);版本必须 exact match # specta 的 serde_json feature 让 `serde_json::Value` 字段能被 #[derive(Type)] 接受 specta = { version = "=2.0.0-rc.25", features = ["serde_json"] } diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 3adead9f..461deac4 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -2,6 +2,7 @@ use std::env; use std::fmt; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use std::time::Duration; #[cfg(windows)] const CLAUDE_FILE_NAMES: &[&str] = &["claude.exe", "claude.cmd", "claude.bat", "claude"]; @@ -12,6 +13,7 @@ const CLAUDE_FILE_NAMES: &[&str] = &["claude"]; pub(crate) enum ClaudeCliError { NotFound, Spawn(std::io::Error), + Timeout, } impl fmt::Display for ClaudeCliError { @@ -22,11 +24,39 @@ impl fmt::Display for ClaudeCliError { "未找到 claude CLI,请确认 Claude Code 已安装并可在 PATH 或标准安装目录中访问" ), Self::Spawn(error) => write!(formatter, "执行 claude CLI 失败: {error}"), + Self::Timeout => write!(formatter, "claude CLI 执行超时,请稍后重试"), } } } pub(crate) fn run(args: &[String]) -> Result { + let mut command = build_command(args)?; + command.output().map_err(ClaudeCliError::Spawn) +} + +pub(crate) async fn run_with_timeout( + args: &[String], + timeout: Duration, +) -> Result { + let command = build_command(args)?; + run_command_with_timeout(command, timeout).await +} + +async fn run_command_with_timeout( + command: Command, + timeout: Duration, +) -> Result { + let mut command = tokio::process::Command::from(command); + // 超时会丢弃 output future;终止其子进程,避免后台残留执行中的 CLI。 + command.kill_on_drop(true); + command.stdin(std::process::Stdio::null()); + tokio::time::timeout(timeout, command.output()) + .await + .map_err(|_| ClaudeCliError::Timeout)? + .map_err(ClaudeCliError::Spawn) +} + +fn build_command(args: &[String]) -> Result { // 保留调用进程显式 PATH 的优先级,再覆盖 GUI 应用缺少 shell PATH 的标准安装场景。 let program = path_executable() .or_else(|| native_installer_path().filter(|path| is_executable(path))) @@ -38,7 +68,7 @@ pub(crate) fn run(args: &[String]) -> Result { let mut command = Command::new(program); command.args(args); crate::utils::hide_command_window(&mut command); - command.output().map_err(ClaudeCliError::Spawn) + Ok(command) } fn path_executable() -> Option { @@ -118,11 +148,62 @@ fn is_executable(path: &Path) -> bool { #[cfg(all(test, unix))] mod tests { - use super::run; + use super::{run, run_command_with_timeout, ClaudeCliError}; use std::env; use std::ffi::OsString; use std::fs; + #[test] + fn command_timeout_terminates_child_without_blocking_runtime() { + use std::process::Command; + use std::time::Duration; + + let root = tempfile::tempdir().unwrap(); + let marker = root.path().join("finished"); + let mut command = Command::new("/bin/sh"); + command.args([ + "-c", + "/bin/sleep 0.2; printf finished > \"$1\"", + "diagnostic", + ]); + command.arg(&marker); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let task = tokio::spawn(run_command_with_timeout(command, Duration::from_millis(50))); + tokio::time::sleep(Duration::from_millis(10)).await; + assert!(!marker.exists()); + let result = task.await.unwrap(); + assert!(matches!(result, Err(ClaudeCliError::Timeout))); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!(!marker.exists(), "超时后 CLI 不应继续写入"); + }); + } + + #[test] + fn command_with_timeout_collects_large_output_and_exit_status() { + use std::process::Command; + use std::time::Duration; + + let mut command = Command::new("/bin/sh"); + command.args([ + "-c", + "/bin/dd if=/dev/zero bs=1024 count=600 2>/dev/null; printf failure >&2; exit 7", + ]); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let output = runtime + .block_on(run_command_with_timeout(command, Duration::from_secs(5))) + .unwrap(); + assert_eq!(output.stdout.len(), 600 * 1024); + assert_eq!(output.stderr, b"failure"); + assert_eq!(output.status.code(), Some(7)); + } + struct EnvVarGuard { key: &'static str, original: Option, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e56e3f1..cc2a00c3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,7 +63,7 @@ use memory::{ preview_delete_memory, toggle_memory, update_memory, }; use native_open::get_native_open_app_options; -use plugins::refresh_plugin_install_counts; +use plugins::{read_plugin_catalog, refresh_plugin_install_counts}; use project::{ cleanup_project_branches, cleanup_project_worktrees, create_project_agents_skills_symlink, create_project_agents_symlink, create_project_claude_settings_file, @@ -183,6 +183,7 @@ fn build_specta_builder() -> tauri_specta::Builder { refresh_usage_pricing, rescan_usage, refresh_plugin_install_counts, + read_plugin_catalog, led_probe_status, led_test_mode, get_sleep_prevention_status, diff --git a/src-tauri/src/plugins.rs b/src-tauri/src/plugins.rs index ee2d710d..70b8bf88 100644 --- a/src-tauri/src/plugins.rs +++ b/src-tauri/src/plugins.rs @@ -1,3 +1,7 @@ +use serde_json::Value; +use std::path::Path; +use std::time::Duration; + /// 触发 claude 读取插件目录缓存,按其默认 24h TTL 策略刷新安装数。 /// /// 不主动删缓存、不强制刷新:执行 `claude plugin list --available --json`,claude 内部若发现 @@ -7,8 +11,8 @@ /// 成功/失败,CLI 原始输出仅进后端日志。 #[tauri::command] #[specta::specta] -pub fn refresh_plugin_install_counts() -> Result<(), String> { - let result = trigger_claude_catalog_refresh(); +pub async fn refresh_plugin_install_counts() -> Result<(), String> { + let result = trigger_claude_catalog_refresh().await; crate::logging::log_command_result("plugins.refresh_install_counts", &result, |_| { String::new() }); @@ -17,9 +21,11 @@ pub fn refresh_plugin_install_counts() -> Result<(), String> { // 执行 `claude plugin list --available --json`:claude 读取 catalog 时按 TTL 决定是否重拉缓存。 // 输出仅用于失败诊断,不回传 UI。 -fn trigger_claude_catalog_refresh() -> Result<(), String> { +async fn trigger_claude_catalog_refresh() -> Result<(), String> { let args = ["plugin", "list", "--available", "--json"].map(str::to_string); - let output = crate::claude_cli::run(&args).map_err(|error| error.to_string())?; + let output = crate::claude_cli::run_with_timeout(&args, Duration::from_secs(30)) + .await + .map_err(|error| error.to_string())?; if output.status.success() { Ok(()) @@ -38,3 +44,58 @@ fn trigger_claude_catalog_refresh() -> Result<(), String> { }) } } + +/// 专用读取完整 catalog,避免文件预览接口的截断规则影响安装数。 +#[tauri::command] +#[specta::specta] +pub async fn read_plugin_catalog() -> Result, String> { + tauri::async_runtime::spawn_blocking(|| { + let root = crate::utils::get_claude_dir()?; + let catalog = read_plugin_catalog_from_root(&root)?; + Ok(if catalog.is_null() { + None + } else { + Some(catalog.to_string()) + }) + }) + .await + .map_err(|_| "读取插件安装数缓存任务失败".to_string())? +} + +fn read_plugin_catalog_from_root(root: &Path) -> Result { + let path = root.join("plugins/plugin-catalog-cache.json"); + if !path.exists() { + return Ok(Value::Null); + } + crate::utils::read_json_file_strict(&path) +} + +#[cfg(test)] +mod tests { + use super::read_plugin_catalog_from_root; + use serde_json::json; + + #[test] + fn reads_complete_catalog_larger_than_preview_limit() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("plugins/plugin-catalog-cache.json"); + let catalog = json!({ + "padding": "x".repeat(600 * 1024), + "catalog": {"plugins": {"alpha@claude-plugins-official": {"unique_installs": 1234}}} + }); + crate::utils::save_json_file(&path, &catalog).unwrap(); + assert_eq!(read_plugin_catalog_from_root(root.path()).unwrap(), catalog); + } + + #[test] + fn missing_catalog_is_empty_but_invalid_json_is_an_error() { + let root = tempfile::tempdir().unwrap(); + assert_eq!( + read_plugin_catalog_from_root(root.path()).unwrap(), + json!(null) + ); + let path = root.path().join("plugins/plugin-catalog-cache.json"); + crate::utils::ensure_dir_and_write_atomic(&path, "invalid json").unwrap(); + assert!(read_plugin_catalog_from_root(root.path()).is_err()); + } +} diff --git a/src/bindings.ts b/src/bindings.ts index f09c1c00..bed83034 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -130,6 +130,8 @@ export const commands = { * 成功/失败,CLI 原始输出仅进后端日志。 */ refreshPluginInstallCounts: () => typedError(__TAURI_INVOKE("refresh_plugin_install_counts")), + /** 专用读取完整 catalog,避免文件预览接口的截断规则影响安装数。 */ + readPluginCatalog: () => typedError(__TAURI_INVOKE("read_plugin_catalog")), /** 探测 ANTICATER 设备是否连接(设置页显式调用)。 */ ledProbeStatus: () => __TAURI_INVOKE("led_probe_status"), /** 测试某个灯效(设置页「测试」按钮 / 真机验证门)。立即下发,不受 enabled 影响。 */ diff --git a/src/components/profile-editor/BrowseMarketplaceTab.tsx b/src/components/profile-editor/BrowseMarketplaceTab.tsx index fbd479f6..bb0bccdd 100644 --- a/src/components/profile-editor/BrowseMarketplaceTab.tsx +++ b/src/components/profile-editor/BrowseMarketplaceTab.tsx @@ -531,15 +531,27 @@ export default function BrowseMarketplaceTab({ ); } - function formatRefreshSuccessDescription( - summaries: Awaited>, - ): string { + function formatRefreshDescription(summaries: Awaited>): string { return summaries .map((summary) => - formatTemplate(t("profileEditor.plugins.browse.refreshSuccessItem"), { - marketplace: summary.marketplaceId, - count: summary.pluginCount, - }), + summary.status === "error" + ? formatTemplate(t("profileEditor.plugins.browse.refreshFailureItem"), { + marketplace: summary.marketplaceId, + error: summary.timedOut + ? t("profileEditor.plugins.browse.requestTimeout") + : (summary.error ?? ""), + }) + : [ + formatTemplate(t("profileEditor.plugins.browse.refreshSuccessItem"), { + marketplace: summary.marketplaceId, + count: summary.pluginCount, + }), + summary.cacheSaved === false + ? t("profileEditor.plugins.browse.cacheUnavailable") + : "", + ] + .filter(Boolean) + .join(" · "), ) .join("\n"); } @@ -551,7 +563,7 @@ export default function BrowseMarketplaceTab({ let summaries: Awaited> = []; let installCountsError: string | null = null; try { - // GitHub 插件列表刷新与 claude catalog 重拉并发;后者失败降级,不拖垮列表刷新 + // 列表与安装数并发刷新,各自有超时边界;安装数失败时保留列表结果。 [summaries] = await Promise.all([ refreshAll(), ipc.refreshPluginInstallCounts().catch((error) => { @@ -561,6 +573,11 @@ export default function BrowseMarketplaceTab({ // catalog 缓存重拉后重读完整 catalog(本地读取,廉价) const next = await loadPluginCatalog(); setCatalog(next); + } catch (error) { + showToast(t("profileEditor.plugins.browse.refreshFailure"), "error", { + description: error instanceof Error ? error.message : String(error), + }); + return; } finally { const remainingMs = MIN_REFRESH_FEEDBACK_MS - (Date.now() - startedAt); if (remainingMs > 0) { @@ -568,13 +585,29 @@ export default function BrowseMarketplaceTab({ } setRefreshingAll(false); } - if (installCountsError) { + const failed = summaries.filter((summary) => summary.status === "error"); + if (failed.length > 0) { + const supported = summaries.filter((summary) => !summary.unsupported); + showToast( + t( + failed.length === supported.length + ? "profileEditor.plugins.browse.refreshFailure" + : "profileEditor.plugins.browse.refreshPartialFailure", + ), + "error", + { + description: [formatRefreshDescription(summaries), installCountsError] + .filter(Boolean) + .join("\n"), + }, + ); + } else if (installCountsError) { showToast(t("profileEditor.plugins.browse.installCountsRefreshFailed"), "error", { description: installCountsError, }); } else { showToast(t("profileEditor.plugins.browse.refreshSuccess"), "success", { - description: formatRefreshSuccessDescription(summaries), + description: formatRefreshDescription(summaries), }); } } @@ -851,7 +884,11 @@ export default function BrowseMarketplaceTab({
{failure.marketplaceId}
{failure.error && ( -
{failure.error}
+
+ {failure.timedOut + ? t("profileEditor.plugins.browse.requestTimeout") + : failure.error} +
)}