mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 04:22:58 +08:00
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.
This commit is contained in:
@@ -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::*;
|
||||
|
||||
@@ -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::ToolSearchStatus, String>
|
||||
{
|
||||
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<Vec<crate::toolsearch_patch::PatchResult>, 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<Vec<crate::toolsearch_patch::PatchResult>, String>
|
||||
{
|
||||
crate::toolsearch_patch::restore_toolsearch_patch().map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// Overall Tool Search patch status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolSearchStatus {
|
||||
pub installations: Vec<ClaudeInstallation>,
|
||||
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<Vec<u8>, 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<u8>, 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf>,
|
||||
seen: &mut std::collections::HashSet<PathBuf>,
|
||||
path: PathBuf,
|
||||
) {
|
||||
if path.is_dir() && seen.insert(path.clone()) {
|
||||
candidates.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_active_patch_target(command_path: &Path) -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf, AppError> {
|
||||
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<PathBuf, AppError> {
|
||||
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<PathBuf, AppError> {
|
||||
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<ToolSearchStatus, AppError> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<Vec<PatchResult>, 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<Vec<PatchResult>, 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());
|
||||
}
|
||||
}
|
||||
@@ -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 })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<Search className="h-4 w-4 text-amber-500" />}
|
||||
title={t("settings.toolSearchBypass")}
|
||||
description={t("settings.toolSearchBypassDescription")}
|
||||
checked={!!settings.toolSearchBypass}
|
||||
onCheckedChange={(value) => onChange({ toolSearchBypass: value })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<AppWindow className="h-4 w-4 text-blue-500" />}
|
||||
title={t("settings.minimizeToTray")}
|
||||
|
||||
@@ -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 标签页的手动保存)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ホームページに表示するアプリを選択",
|
||||
|
||||
@@ -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": "选择在主页面显示的应用",
|
||||
|
||||
@@ -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<string | null> {
|
||||
return await invoke("save_file_dialog", { defaultName });
|
||||
},
|
||||
|
||||
@@ -226,6 +226,8 @@ export interface Settings {
|
||||
enableClaudePluginIntegration?: boolean;
|
||||
// 跳过 Claude Code 初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding)
|
||||
skipClaudeOnboarding?: boolean;
|
||||
// 解除 Tool Search 域名限制
|
||||
toolSearchBypass?: boolean;
|
||||
// 是否开机自启
|
||||
launchOnStartup?: boolean;
|
||||
// 静默启动(程序启动时不显示主窗口)
|
||||
|
||||
Reference in New Issue
Block a user