From 9439153f056008d0611a011bc60d38323b89dbad Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 14 Mar 2026 21:20:05 +0800 Subject: [PATCH] feat: add Tool Search domain restriction bypass with active-installation patching Resolve the active `claude` command from PATH and apply an equal-length byte patch to remove the domain whitelist check. Backups are stored in ~/.cc-switch/toolsearch-backups/ (SHA-256 of path) so they survive Claude Code version upgrades. The patch auto-reapplies on app startup when the setting is enabled. Frontend checks PatchResult.success and rolls back the setting on failure. --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/toolsearch.rs | 21 + src-tauri/src/lib.rs | 26 + src-tauri/src/settings.rs | 4 + src-tauri/src/toolsearch_patch.rs | 569 +++++++++++++++++++++ src/components/settings/WindowSettings.tsx | 10 +- src/hooks/useSettings.ts | 40 +- src/hooks/useSettingsForm.ts | 3 + src/i18n/locales/en.json | 4 + src/i18n/locales/ja.json | 4 + src/i18n/locales/zh.json | 4 + src/lib/api/settings.ts | 12 + src/types.ts | 2 + 13 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/toolsearch.rs create mode 100644 src-tauri/src/toolsearch_patch.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5de019f96..419b665dd 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -19,6 +19,7 @@ mod settings; pub mod skill; mod stream_check; mod sync_support; +mod toolsearch; mod usage; mod webdav_sync; mod workspace; @@ -41,6 +42,7 @@ pub use session_manager::*; pub use settings::*; pub use skill::*; pub use stream_check::*; +pub use toolsearch::*; pub use usage::*; pub use webdav_sync::*; pub use workspace::*; diff --git a/src-tauri/src/commands/toolsearch.rs b/src-tauri/src/commands/toolsearch.rs new file mode 100644 index 000000000..15ee14cbc --- /dev/null +++ b/src-tauri/src/commands/toolsearch.rs @@ -0,0 +1,21 @@ +#![allow(non_snake_case)] + +/// Check Tool Search patch status for the active Claude Code installation +#[tauri::command] +pub async fn check_toolsearch_status() -> Result +{ + crate::toolsearch_patch::check_toolsearch_status().map_err(|e| e.to_string()) +} + +/// Apply Tool Search patch (bypass domain restriction) to the active installation +#[tauri::command] +pub async fn apply_toolsearch_patch() -> Result, String> { + crate::toolsearch_patch::apply_toolsearch_patch().map_err(|e| e.to_string()) +} + +/// Restore Tool Search patch (re-enable domain restriction) for the active installation +#[tauri::command] +pub async fn restore_toolsearch_patch() -> Result, String> +{ + crate::toolsearch_patch::restore_toolsearch_patch().map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 426f991f4..ab977d814 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod services; mod session_manager; mod settings; mod store; +mod toolsearch_patch; mod tray; mod usage_script; @@ -805,6 +806,27 @@ pub fn run() { } } + // Tool Search bypass: auto-apply patch on startup if enabled + if settings.tool_search_bypass { + match crate::toolsearch_patch::apply_toolsearch_patch() { + Ok(results) => { + let success = results.iter().filter(|r| r.success).count(); + let total = results.len(); + if success > 0 { + log::info!("✓ Tool Search patch auto-applied ({success}/{total})"); + } + for r in results.iter().filter(|r| !r.success) { + if let Some(err) = &r.error { + log::warn!("✗ Tool Search patch failed for {}: {err}", r.path); + } + } + } + Err(e) => { + log::warn!("✗ Tool Search auto-patch skipped: {e}"); + } + } + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -851,6 +873,10 @@ pub fn run() { commands::is_claude_plugin_applied, commands::apply_claude_onboarding_skip, commands::clear_claude_onboarding_skip, + // Tool Search patch + commands::check_toolsearch_status, + commands::apply_toolsearch_patch, + commands::restore_toolsearch_patch, // Claude MCP management commands::get_claude_mcp_status, commands::read_claude_mcp_config, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 2358e622f..8d7ba94e7 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -181,6 +181,9 @@ pub struct AppSettings { /// 是否跳过 Claude Code 初次安装确认 #[serde(default)] pub skip_claude_onboarding: bool, + /// 是否解除 Tool Search 域名限制 + #[serde(default)] + pub tool_search_bypass: bool, /// 是否开机自启 #[serde(default)] pub launch_on_startup: bool, @@ -286,6 +289,7 @@ impl Default for AppSettings { minimize_to_tray_on_close: true, enable_claude_plugin_integration: false, skip_claude_onboarding: false, + tool_search_bypass: false, launch_on_startup: false, silent_startup: false, enable_local_proxy: false, diff --git a/src-tauri/src/toolsearch_patch.rs b/src-tauri/src/toolsearch_patch.rs new file mode 100644 index 000000000..f36d307ac --- /dev/null +++ b/src-tauri/src/toolsearch_patch.rs @@ -0,0 +1,569 @@ +//! Tool Search domain restriction bypass patch for Claude Code. +//! +//! Resolves the current active `claude` command from PATH and patches the +//! domain whitelist check +//! `return["api.anthropic.com"].includes(x)}catch{return!1}` +//! to always return true via equal-length byte replacement. + +use regex::bytes::Regex; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use sha2::{Digest, Sha256}; + +use crate::error::AppError; + +const BACKUP_SUFFIX: &str = ".toolsearch-bak"; + +/// Encode bytes as lowercase hex string (avoids adding `hex` crate dependency). +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +// Regex matching the domain whitelist check with any JS identifier as variable name +const PATCH_TARGET_PATTERN: &str = + r#"return\["api\.anthropic\.com"\]\.includes\([A-Za-z_$][A-Za-z0-9_$]*\)\}catch\{return!1\}"#; + +// Regex matching already-patched code +const PATCHED_PATTERN: &str = r#"return!0/\* *\*/\}catch\{return!0\}"#; + +/// Single Claude Code installation info +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeInstallation { + pub path: String, + pub source: String, + pub patched: bool, + pub has_backup: bool, +} + +/// Result of a patch/restore operation on one installation +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PatchResult { + pub path: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Overall Tool Search patch status +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolSearchStatus { + pub installations: Vec, + pub all_patched: bool, + pub any_found: bool, +} + +// ── Patch status detection ────────────────────────────────────────── + +fn get_patch_status(data: &[u8]) -> &'static str { + let target_re = Regex::new(PATCH_TARGET_PATTERN).unwrap(); + let patched_re = Regex::new(PATCHED_PATTERN).unwrap(); + if target_re.is_match(data) { + "unpatched" + } else if patched_re.is_match(data) { + "patched" + } else { + "unknown" + } +} + +/// Build equal-length replacement bytes: `return!0/* */}catch{return!0}` +fn build_patched_bytes(original_len: usize) -> Result, AppError> { + let prefix = b"return!0/*"; + let suffix = b"*/}catch{return!0}"; + let padding = original_len + .checked_sub(prefix.len() + suffix.len()) + .ok_or_else(|| AppError::Config("Patch template too long for match".into()))?; + let mut out = Vec::with_capacity(original_len); + out.extend_from_slice(prefix); + out.extend(std::iter::repeat_n(b' ', padding)); + out.extend_from_slice(suffix); + Ok(out) +} + +/// Apply byte-level patch to file data, returns (patched_data, replacement_count) +fn patch_bytes(data: &[u8]) -> Result<(Vec, usize), AppError> { + let re = Regex::new(PATCH_TARGET_PATTERN).unwrap(); + let mut count = 0usize; + let result = re.replace_all(data, |caps: ®ex::bytes::Captures| { + count += 1; + build_patched_bytes(caps[0].len()).unwrap_or_else(|_| caps[0].to_vec()) + }); + Ok((result.into_owned(), count)) +} + +// ── Installation detection ────────────────────────────────────────── + +/// Run a command and return stdout, or empty string on failure +fn run_cmd(cmd: &str, args: &[&str]) -> String { + Command::new(cmd) + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Search a package directory for JS files containing the domain check +fn find_patch_target_in_pkg(pkg_dir: &Path) -> Option { + let marker = b"api.anthropic.com"; + // Check cli.js first (most common) + let cli_js = pkg_dir.join("cli.js"); + if cli_js.is_file() { + if let Ok(data) = std::fs::read(&cli_js) { + if data.windows(marker.len()).any(|w| w == marker) { + return Some(cli_js); + } + } + } + // Search other JS files + find_js_with_marker(pkg_dir) +} + +fn find_js_with_marker(dir: &Path) -> Option { + let marker = b"api.anthropic.com"; + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return None, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_js_with_marker(&path) { + return Some(found); + } + } else if path.extension().and_then(|e| e.to_str()) == Some("js") { + if let Ok(meta) = path.metadata() { + if meta.len() < 1000 { + continue; + } + } + if let Ok(data) = std::fs::read(&path) { + if data.windows(marker.len()).any(|w| w == marker) { + return Some(path); + } + } + } + } + None +} + +/// Resolve symlinks to actual file path +fn resolve_target(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn get_patch_status_for_path(path: &Path) -> Option<&'static str> { + let data = std::fs::read(path).ok()?; + Some(get_patch_status(&data)) +} + +fn package_dir_from_ancestors(path: &Path) -> Option { + for ancestor in path.ancestors() { + if ancestor.file_name().and_then(|v| v.to_str()) == Some("claude-code") + && ancestor.parent().and_then(|v| v.file_name()).and_then(|v| v.to_str()) + == Some("@anthropic-ai") + { + return Some(ancestor.to_path_buf()); + } + } + None +} + +fn push_candidate_package_dir( + candidates: &mut Vec, + seen: &mut std::collections::HashSet, + path: PathBuf, +) { + if path.is_dir() && seen.insert(path.clone()) { + candidates.push(path); + } +} + +fn resolve_active_patch_target(command_path: &Path) -> Option { + let resolved_command = resolve_target(command_path); + if matches!( + get_patch_status_for_path(&resolved_command), + Some("patched" | "unpatched") + ) { + return Some(resolved_command); + } + + let mut candidates = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for path in [command_path, resolved_command.as_path()] { + if let Some(pkg_dir) = package_dir_from_ancestors(path) { + push_candidate_package_dir(&mut candidates, &mut seen, pkg_dir); + } + + if let Some(bin_dir) = path.parent() { + if let Some(prefix) = bin_dir.parent() { + push_candidate_package_dir( + &mut candidates, + &mut seen, + prefix + .join("lib") + .join("node_modules") + .join("@anthropic-ai") + .join("claude-code"), + ); + push_candidate_package_dir( + &mut candidates, + &mut seen, + prefix + .join("node_modules") + .join("@anthropic-ai") + .join("claude-code"), + ); + } + } + } + + candidates + .into_iter() + .find_map(|pkg_dir| find_patch_target_in_pkg(&pkg_dir).map(|p| resolve_target(&p))) +} + +#[cfg(target_os = "windows")] +fn find_active_command_path() -> Option { + run_cmd("where.exe", &["claude"]) + .lines() + .next() + .map(PathBuf::from) + .filter(|path| path.is_file()) +} + +#[cfg(not(target_os = "windows"))] +fn find_active_command_path() -> Option { + run_cmd("which", &["claude"]) + .lines() + .next() + .map(PathBuf::from) + .filter(|path| path.is_file()) +} + +fn find_active_installation() -> Option<(PathBuf, String)> { + let command_path = find_active_command_path()?; + let patch_target = resolve_active_patch_target(&command_path)?; + Some(( + patch_target, + format!("active claude ({})", command_path.display()), + )) +} + +fn require_active_installation() -> Result<(PathBuf, String), AppError> { + find_active_installation().ok_or_else(|| { + AppError::Config("No active Claude Code installation found in PATH".into()) + }) +} + +// ── macOS codesign ────────────────────────────────────────────────── + +#[cfg(target_os = "macos")] +fn codesign_adhoc(path: &Path) -> Result<(), AppError> { + let output = Command::new("codesign") + .args(["--force", "--sign", "-"]) + .arg(path) + .output() + .map_err(|e| AppError::IoContext { + context: format!("Failed to run codesign for {}", path.display()), + source: e, + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AppError::Config(format!( + "codesign failed for {}: {}", + path.display(), + stderr.trim() + ))); + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn codesign_adhoc(_path: &Path) -> Result<(), AppError> { + Ok(()) +} + +// ── Backup directory helpers ───────────────────────────────────────── + +/// Get the centralized backup directory: `~/.cc-switch/toolsearch-backups/` +fn get_backup_dir() -> Result { + let dir = crate::config::get_app_config_dir().join("toolsearch-backups"); + if !dir.exists() { + std::fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?; + } + Ok(dir) +} + +/// Derive a stable backup filename from the original path using SHA-256. +fn backup_name_for(path: &Path) -> String { + let mut hasher = Sha256::new(); + hasher.update(path.to_string_lossy().as_bytes()); + to_hex(&hasher.finalize()) +} + +/// Get the backup file path for a given target file. +fn get_backup_path(path: &Path) -> Result { + let dir = get_backup_dir()?; + let name = backup_name_for(path); + Ok(dir.join(format!("{name}.bak"))) +} + +/// Get the metadata file path (records original path for debugging). +fn get_meta_path(path: &Path) -> Result { + let dir = get_backup_dir()?; + let name = backup_name_for(path); + Ok(dir.join(format!("{name}.meta"))) +} + +// ── Patch / Restore single file ───────────────────────────────────── + +fn patch_single_file(path: &Path) -> Result<(), AppError> { + let data = std::fs::read(path).map_err(|e| AppError::io(path, e))?; + let status = get_patch_status(&data); + + if status == "patched" { + return Ok(()); // Already patched + } + if status == "unknown" { + return Err(AppError::Config(format!( + "Target pattern not found in {}, possibly incompatible version", + path.display() + ))); + } + + let (patched_data, count) = patch_bytes(&data)?; + if count == 0 { + return Err(AppError::Config(format!( + "No replacements made in {}", + path.display() + ))); + } + + // Create backup in centralized directory + let backup_path = get_backup_path(path)?; + std::fs::copy(path, &backup_path).map_err(|e| AppError::io(&backup_path, e))?; + + // Write metadata file for debugging + let meta_path = get_meta_path(path)?; + let _ = std::fs::write(&meta_path, path.to_string_lossy().as_bytes()); + + // Write patched data + if let Err(e) = std::fs::write(path, &patched_data) { + // Try rename trick on Windows + #[cfg(target_os = "windows")] + { + if let Ok(()) = write_via_rename(path, &patched_data) { + codesign_adhoc(path)?; + return Ok(()); + } + } + return Err(AppError::io(path, e)); + } + + codesign_adhoc(path)?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn write_via_rename(target: &Path, data: &[u8]) -> Result<(), AppError> { + let tmp_path = target.with_extension("tmp"); + let old_path = target.with_extension("old"); + + let _ = std::fs::remove_file(&tmp_path); + let _ = std::fs::remove_file(&old_path); + + std::fs::write(&tmp_path, data).map_err(|e| AppError::io(&tmp_path, e))?; + std::fs::rename(target, &old_path).map_err(|e| AppError::io(target, e))?; + std::fs::rename(&tmp_path, target).map_err(|e| AppError::io(target, e))?; + let _ = std::fs::remove_file(&old_path); + Ok(()) +} + +fn restore_single_file(path: &Path) -> Result<(), AppError> { + let current_status = get_patch_status_for_path(path); + if matches!(current_status, Some("unpatched")) { + return Ok(()); + } + + // Try centralized backup directory first + let backup_path = get_backup_path(path)?; + // Fallback: legacy backup path (adjacent `.toolsearch-bak` file) + let legacy_backup = PathBuf::from(format!("{}{}", path.display(), BACKUP_SUFFIX)); + + let actual_backup = if backup_path.is_file() { + &backup_path + } else if legacy_backup.is_file() { + &legacy_backup + } else { + return Err(AppError::Config(format!( + "No backup found for {}", + path.display() + ))); + }; + + let backup_data = std::fs::read(actual_backup).map_err(|e| AppError::io(actual_backup, e))?; + + if let Err(e) = std::fs::write(path, &backup_data) { + #[cfg(target_os = "windows")] + { + if let Ok(()) = write_via_rename(path, &backup_data) { + codesign_adhoc(path)?; + return Ok(()); + } + } + return Err(AppError::io(path, e)); + } + + codesign_adhoc(path)?; + Ok(()) +} + +// ── Public API ────────────────────────────────────────────────────── + +/// Check Tool Search patch status for the current active Claude Code installation. +pub fn check_toolsearch_status() -> Result { + let installations = find_active_installation() + .into_iter() + .map(|(path, source)| { + let data = std::fs::read(&path).unwrap_or_default(); + let status = get_patch_status(&data); + // Check centralized backup first, then legacy + let has_backup = get_backup_path(&path) + .map(|p| p.is_file()) + .unwrap_or(false) + || PathBuf::from(format!("{}{}", path.display(), BACKUP_SUFFIX)).is_file(); + ClaudeInstallation { + path: path.to_string_lossy().to_string(), + source: source.clone(), + patched: status == "patched", + has_backup, + } + }) + .collect::>(); + + let any_found = !installations.is_empty(); + let all_patched = any_found && installations.iter().all(|i| i.patched); + + Ok(ToolSearchStatus { + installations, + all_patched, + any_found, + }) +} + +/// Apply the Tool Search patch to the current active Claude Code installation. +pub fn apply_toolsearch_patch() -> Result, AppError> { + let (path, _) = require_active_installation()?; + Ok(vec![match patch_single_file(&path) { + Ok(()) => PatchResult { + path: path.to_string_lossy().to_string(), + success: true, + error: None, + }, + Err(e) => PatchResult { + path: path.to_string_lossy().to_string(), + success: false, + error: Some(e.to_string()), + }, + }]) +} + +/// Restore the current active Claude Code installation from backup. +pub fn restore_toolsearch_patch() -> Result, AppError> { + let (path, _) = require_active_installation()?; + Ok(vec![match restore_single_file(&path) { + Ok(()) => PatchResult { + path: path.to_string_lossy().to_string(), + success: true, + error: None, + }, + Err(e) => PatchResult { + path: path.to_string_lossy().to_string(), + success: false, + error: Some(e.to_string()), + }, + }]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_patch_bytes_replaces_correctly() { + let input = br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#; + let (patched, count) = patch_bytes(input).unwrap(); + assert_eq!(count, 1); + assert_eq!(patched.len(), input.len()); + assert!(patched.starts_with(b"return!0/*")); + assert!(patched.ends_with(b"*/}catch{return!0}")); + } + + #[test] + fn test_patch_status_detection() { + let unpatched = br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#; + assert_eq!(get_patch_status(unpatched), "unpatched"); + + let (patched, _) = patch_bytes(unpatched).unwrap(); + assert_eq!(get_patch_status(&patched), "patched"); + + assert_eq!(get_patch_status(b"some random data"), "unknown"); + } + + #[test] + fn test_build_patched_bytes_length() { + for len in 50..70 { + let result = build_patched_bytes(len).unwrap(); + assert_eq!(result.len(), len); + } + } + + #[test] + fn test_resolve_active_patch_target_from_npm_style_bin_path() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = tmp.path().join("prefix"); + let bin_dir = prefix.join("bin"); + let pkg_dir = prefix + .join("lib") + .join("node_modules") + .join("@anthropic-ai") + .join("claude-code"); + std::fs::create_dir_all(&bin_dir).unwrap(); + std::fs::create_dir_all(&pkg_dir).unwrap(); + + let command_path = bin_dir.join("claude"); + std::fs::write(&command_path, b"#!/usr/bin/env node\n").unwrap(); + let cli_path = pkg_dir.join("cli.js"); + std::fs::write( + &cli_path, + br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#, + ) + .unwrap(); + + assert_eq!( + resolve_active_patch_target(&command_path), + Some(resolve_target(&cli_path)) + ); + } + + #[test] + fn test_restore_single_file_is_noop_when_current_target_is_unpatched() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + tmp.path(), + br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#, + ) + .unwrap(); + + assert!(restore_single_file(tmp.path()).is_ok()); + } +} diff --git a/src/components/settings/WindowSettings.tsx b/src/components/settings/WindowSettings.tsx index a2597aed9..3b02529c9 100644 --- a/src/components/settings/WindowSettings.tsx +++ b/src/components/settings/WindowSettings.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import type { SettingsFormState } from "@/hooks/useSettings"; -import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react"; +import { AppWindow, MonitorUp, Power, EyeOff, Search } from "lucide-react"; import { ToggleRow } from "@/components/ui/toggle-row"; import { AnimatePresence, motion } from "framer-motion"; @@ -66,6 +66,14 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) { onCheckedChange={(value) => onChange({ skipClaudeOnboarding: value })} /> + } + title={t("settings.toolSearchBypass")} + description={t("settings.toolSearchBypassDescription")} + checked={!!settings.toolSearchBypass} + onCheckedChange={(value) => onChange({ toolSearchBypass: value })} + /> + } title={t("settings.minimizeToTray")} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index b24e01e43..37cda8819 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -197,6 +197,44 @@ export function useSettings(): UseSettingsResult { } } + // Tool Search bypass: apply/restore patch when toggled + const nextToolSearchBypass = updates.toolSearchBypass; + if ( + nextToolSearchBypass !== undefined && + nextToolSearchBypass !== (data?.toolSearchBypass ?? false) + ) { + try { + const results = nextToolSearchBypass + ? await settingsApi.applyToolSearchPatch() + : await settingsApi.restoreToolSearchPatch(); + const failed = results.find((r) => !r.success); + if (failed) { + throw new Error(failed.error ?? "Tool Search patch failed"); + } + } catch (error) { + console.warn( + "[useSettings] Failed to sync Tool Search bypass", + error, + ); + // Rollback: revert the setting we already saved + const rolledBack = { + ...payload, + toolSearchBypass: !nextToolSearchBypass, + }; + await saveMutation.mutateAsync(rolledBack); + updateSettings({ toolSearchBypass: !nextToolSearchBypass }); + toast.error( + nextToolSearchBypass + ? t("notifications.toolSearchPatchFailed", { + defaultValue: "Tool Search 补丁操作失败", + }) + : t("notifications.toolSearchRestoreFailed", { + defaultValue: "Tool Search 恢复操作失败", + }), + ); + } + } + // 持久化语言偏好 try { if (typeof window !== "undefined" && updates.language) { @@ -228,7 +266,7 @@ export function useSettings(): UseSettingsResult { throw error; } }, - [data, saveMutation, settings, t], + [data, saveMutation, settings, t, updateSettings], ); // 完整保存设置(用于 Advanced 标签页的手动保存) diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index 72530920b..dbbd026e3 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -85,6 +85,7 @@ export function useSettingsForm(): UseSettingsFormResult { data.enableClaudePluginIntegration ?? false, silentStartup: data.silentStartup ?? false, skipClaudeOnboarding: data.skipClaudeOnboarding ?? false, + toolSearchBypass: data.toolSearchBypass ?? false, claudeConfigDir: sanitizeDir(data.claudeConfigDir), codexConfigDir: sanitizeDir(data.codexConfigDir), geminiConfigDir: sanitizeDir(data.geminiConfigDir), @@ -107,6 +108,7 @@ export function useSettingsForm(): UseSettingsFormResult { minimizeToTrayOnClose: true, enableClaudePluginIntegration: false, skipClaudeOnboarding: false, + toolSearchBypass: false, language: readPersistedLanguage(), } as SettingsFormState); @@ -143,6 +145,7 @@ export function useSettingsForm(): UseSettingsFormResult { serverData.enableClaudePluginIntegration ?? false, silentStartup: serverData.silentStartup ?? false, skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false, + toolSearchBypass: serverData.toolSearchBypass ?? false, claudeConfigDir: sanitizeDir(serverData.claudeConfigDir), codexConfigDir: sanitizeDir(serverData.codexConfigDir), geminiConfigDir: sanitizeDir(serverData.geminiConfigDir), diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 96e61f013..0b7261e79 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -166,6 +166,8 @@ "syncClaudePluginFailed": "Sync Claude plugin failed", "skipClaudeOnboardingFailed": "Failed to skip Claude Code first-run confirmation", "clearClaudeOnboardingSkipFailed": "Failed to restore Claude Code first-run confirmation", + "toolSearchPatchFailed": "Failed to apply Tool Search patch", + "toolSearchRestoreFailed": "Failed to restore Tool Search patch", "updateSuccess": "Provider updated successfully", "updateFailed": "Failed to update provider: {{error}}", "deleteSuccess": "Provider deleted", @@ -463,6 +465,8 @@ "enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app", "skipClaudeOnboarding": "Skip Claude Code first-run confirmation", "skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation", + "toolSearchBypass": "Bypass Tool Search domain restriction", + "toolSearchBypassDescription": "Remove the Tool Search domain whitelist in the active Claude Code installation. Auto-reapplied after that installation updates", "appVisibility": { "title": "Homepage Display", "description": "Choose which apps to show on the homepage", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 39c8c6214..e6a6dd35b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -166,6 +166,8 @@ "syncClaudePluginFailed": "Claude プラグインとの同期に失敗しました", "skipClaudeOnboardingFailed": "Claude Code の初回確認スキップに失敗しました", "clearClaudeOnboardingSkipFailed": "Claude Code の初回確認の復元に失敗しました", + "toolSearchPatchFailed": "Tool Search パッチの適用に失敗しました", + "toolSearchRestoreFailed": "Tool Search パッチの復元に失敗しました", "updateSuccess": "プロバイダーを更新しました", "updateFailed": "プロバイダーの更新に失敗しました: {{error}}", "deleteSuccess": "プロバイダーを削除しました", @@ -463,6 +465,8 @@ "enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します", "skipClaudeOnboarding": "Claude Code の初回確認をスキップ", "skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします", + "toolSearchBypass": "Tool Search ドメイン制限を解除", + "toolSearchBypassDescription": "現在アクティブな Claude Code インストールの Tool Search ドメインホワイトリスト制限を解除します。そのインストールの更新後は自動で再適用されます", "appVisibility": { "title": "ホームページ表示", "description": "ホームページに表示するアプリを選択", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 83e4caa23..358754ee1 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -166,6 +166,8 @@ "syncClaudePluginFailed": "同步 Claude 插件失败", "skipClaudeOnboardingFailed": "跳过 Claude Code 初次安装确认失败", "clearClaudeOnboardingSkipFailed": "恢复 Claude Code 初次安装确认失败", + "toolSearchPatchFailed": "Tool Search 补丁操作失败", + "toolSearchRestoreFailed": "Tool Search 恢复操作失败", "updateSuccess": "供应商更新成功", "updateFailed": "更新供应商失败:{{error}}", "deleteSuccess": "供应商已删除", @@ -463,6 +465,8 @@ "enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换", "skipClaudeOnboarding": "跳过 Claude Code 初次安装确认", "skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认", + "toolSearchBypass": "解除 Tool Search 域名限制", + "toolSearchBypassDescription": "解除当前活跃 Claude Code 安装的 Tool Search 域名白名单限制。该安装更新后会自动重新应用", "appVisibility": { "title": "主页面显示", "description": "选择在主页面显示的应用", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 7102e4ba8..ab0c0d4cb 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -86,6 +86,18 @@ export const settingsApi = { return await invoke("clear_claude_onboarding_skip"); }, + async applyToolSearchPatch(): Promise< + Array<{ path: string; success: boolean; error?: string }> + > { + return await invoke("apply_toolsearch_patch"); + }, + + async restoreToolSearchPatch(): Promise< + Array<{ path: string; success: boolean; error?: string }> + > { + return await invoke("restore_toolsearch_patch"); + }, + async saveFileDialog(defaultName: string): Promise { return await invoke("save_file_dialog", { defaultName }); }, diff --git a/src/types.ts b/src/types.ts index 95c2ccc57..9c6455870 100644 --- a/src/types.ts +++ b/src/types.ts @@ -226,6 +226,8 @@ export interface Settings { enableClaudePluginIntegration?: boolean; // 跳过 Claude Code 初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding) skipClaudeOnboarding?: boolean; + // 解除 Tool Search 域名限制 + toolSearchBypass?: boolean; // 是否开机自启 launchOnStartup?: boolean; // 静默启动(程序启动时不显示主窗口)