Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/user-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/user-manual.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Skills 对应 `~/.claude/skills/<id>/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 地址。

Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
85 changes: 83 additions & 2 deletions src-tauri/src/claude_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand All @@ -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 {
Expand All @@ -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<Output, ClaudeCliError> {
let mut command = build_command(args)?;
command.output().map_err(ClaudeCliError::Spawn)
}

pub(crate) async fn run_with_timeout(
args: &[String],
timeout: Duration,
) -> Result<Output, ClaudeCliError> {
let command = build_command(args)?;
run_command_with_timeout(command, timeout).await
}

async fn run_command_with_timeout(
command: Command,
timeout: Duration,
) -> Result<Output, ClaudeCliError> {
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<Command, ClaudeCliError> {
// 保留调用进程显式 PATH 的优先级,再覆盖 GUI 应用缺少 shell PATH 的标准安装场景。
let program = path_executable()
.or_else(|| native_installer_path().filter(|path| is_executable(path)))
Expand All @@ -38,7 +68,7 @@ pub(crate) fn run(args: &[String]) -> Result<Output, ClaudeCliError> {
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<PathBuf> {
Expand Down Expand Up @@ -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<OsString>,
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -183,6 +183,7 @@ fn build_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
refresh_usage_pricing,
rescan_usage,
refresh_plugin_install_counts,
read_plugin_catalog,
led_probe_status,
led_test_mode,
get_sleep_prevention_status,
Expand Down
69 changes: 65 additions & 4 deletions src-tauri/src/plugins.rs
Original file line number Diff line number Diff line change
@@ -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 内部若发现
Expand All @@ -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()
});
Expand All @@ -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(())
Expand All @@ -38,3 +44,58 @@ fn trigger_claude_catalog_refresh() -> Result<(), String> {
})
}
}

/// 专用读取完整 catalog,避免文件预览接口的截断规则影响安装数。
#[tauri::command]
#[specta::specta]
pub async fn read_plugin_catalog() -> Result<Option<String>, 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<Value, String> {
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());
}
}
2 changes: 2 additions & 0 deletions src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ export const commands = {
* 成功/失败,CLI 原始输出仅进后端日志。
*/
refreshPluginInstallCounts: () => typedError<null, string>(__TAURI_INVOKE("refresh_plugin_install_counts")),
/** 专用读取完整 catalog,避免文件预览接口的截断规则影响安装数。 */
readPluginCatalog: () => typedError<string | null, string>(__TAURI_INVOKE("read_plugin_catalog")),
/** 探测 ANTICATER 设备是否连接(设置页显式调用)。 */
ledProbeStatus: () => __TAURI_INVOKE<LedProbeStatus>("led_probe_status"),
/** 测试某个灯效(设置页「测试」按钮 / 真机验证门)。立即下发,不受 enabled 影响。 */
Expand Down
59 changes: 48 additions & 11 deletions src/components/profile-editor/BrowseMarketplaceTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -531,15 +531,27 @@ export default function BrowseMarketplaceTab({
);
}

function formatRefreshSuccessDescription(
summaries: Awaited<ReturnType<typeof refreshAll>>,
): string {
function formatRefreshDescription(summaries: Awaited<ReturnType<typeof refreshAll>>): 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");
}
Expand All @@ -551,7 +563,7 @@ export default function BrowseMarketplaceTab({
let summaries: Awaited<ReturnType<typeof refreshAll>> = [];
let installCountsError: string | null = null;
try {
// GitHub 插件列表刷新与 claude catalog 重拉并发;后者失败降级,不拖垮列表刷新
// 列表与安装数并发刷新,各自有超时边界;安装数失败时保留列表结果。
[summaries] = await Promise.all([
refreshAll(),
ipc.refreshPluginInstallCounts().catch((error) => {
Expand All @@ -561,20 +573,41 @@ 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) {
await delay(remainingMs);
}
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),
});
}
}
Expand Down Expand Up @@ -851,7 +884,11 @@ export default function BrowseMarketplaceTab({
<div>
<div>{failure.marketplaceId}</div>
{failure.error && (
<div className="text-xs text-muted-foreground">{failure.error}</div>
<div className="text-xs text-muted-foreground">
{failure.timedOut
? t("profileEditor.plugins.browse.requestTimeout")
: failure.error}
</div>
)}
</div>
<Button
Expand Down
Loading
Loading