mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
e4b58c7206
Kaku is a WezTerm-derived macOS terminal, so reusing the existing WezTerm-compatible launch path keeps the change small while making it selectable in settings and session resume flows. Constraint: Kaku support should stay macOS-only and avoid introducing a separate launcher model Rejected: Treat Kaku as a silent WezTerm fallback | users could not explicitly choose it in settings Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Kaku on the shared WezTerm-compatible launch path unless upstream drops the start-compatible CLI Tested: pnpm typecheck; pnpm format:check; cargo check --manifest-path src-tauri/Cargo.toml; cargo fmt --manifest-path src-tauri/Cargo.toml --check; cargo test --manifest-path src-tauri/Cargo.toml --lib session_manager::terminal::tests Not-tested: End-to-end launch against a locally installed Kaku.app Related: #1954
1519 lines
49 KiB
Rust
1519 lines
49 KiB
Rust
#![allow(non_snake_case)]
|
||
|
||
use crate::app_config::AppType;
|
||
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||
use crate::services::ProviderService;
|
||
use once_cell::sync::Lazy;
|
||
use regex::Regex;
|
||
use std::collections::HashMap;
|
||
use std::path::{Path, PathBuf};
|
||
use std::str::FromStr;
|
||
use tauri::AppHandle;
|
||
use tauri::State;
|
||
use tauri_plugin_opener::OpenerExt;
|
||
|
||
#[cfg(target_os = "windows")]
|
||
use std::os::windows::process::CommandExt;
|
||
|
||
#[cfg(target_os = "windows")]
|
||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||
|
||
/// 打开外部链接
|
||
#[tauri::command]
|
||
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
||
let url = if url.starts_with("http://") || url.starts_with("https://") {
|
||
url
|
||
} else {
|
||
format!("https://{url}")
|
||
};
|
||
|
||
app.opener()
|
||
.open_url(&url, None::<String>)
|
||
.map_err(|e| format!("打开链接失败: {e}"))?;
|
||
|
||
Ok(true)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn copy_text_to_clipboard(text: String) -> Result<bool, String> {
|
||
// Use spawn_blocking to avoid blocking the async runtime
|
||
// Clipboard access can block on some platforms and may have thread/loop constraints
|
||
tokio::task::spawn_blocking(move || {
|
||
let mut clipboard =
|
||
arboard::Clipboard::new().map_err(|e| format!("访问系统剪贴板失败: {e}"))?;
|
||
clipboard
|
||
.set_text(text)
|
||
.map_err(|e| format!("写入系统剪贴板失败: {e}"))?;
|
||
Ok(true)
|
||
})
|
||
.await
|
||
.map_err(|e| format!("剪贴板任务执行失败: {e}"))?
|
||
}
|
||
|
||
/// 检查更新
|
||
#[tauri::command]
|
||
pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
|
||
handle
|
||
.opener()
|
||
.open_url(
|
||
"https://github.com/farion1231/cc-switch/releases/latest",
|
||
None::<String>,
|
||
)
|
||
.map_err(|e| format!("打开更新页面失败: {e}"))?;
|
||
|
||
Ok(true)
|
||
}
|
||
|
||
/// 判断是否为便携版(绿色版)运行
|
||
#[tauri::command]
|
||
pub async fn is_portable_mode() -> Result<bool, String> {
|
||
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {e}"))?;
|
||
if let Some(dir) = exe_path.parent() {
|
||
Ok(dir.join("portable.ini").is_file())
|
||
} else {
|
||
Ok(false)
|
||
}
|
||
}
|
||
|
||
/// 获取应用启动阶段的初始化错误(若有)。
|
||
/// 用于前端在早期主动拉取,避免事件订阅竞态导致的提示缺失。
|
||
#[tauri::command]
|
||
pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
|
||
Ok(crate::init_status::get_init_error())
|
||
}
|
||
|
||
/// 获取 JSON→SQLite 迁移结果(若有)。
|
||
/// 只返回一次 true,之后返回 false,用于前端显示一次性 Toast 通知。
|
||
#[tauri::command]
|
||
pub async fn get_migration_result() -> Result<bool, String> {
|
||
Ok(crate::init_status::take_migration_success())
|
||
}
|
||
|
||
/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。
|
||
/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。
|
||
#[tauri::command]
|
||
pub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {
|
||
Ok(crate::init_status::take_skills_migration_result())
|
||
}
|
||
|
||
#[derive(serde::Serialize)]
|
||
pub struct ToolVersion {
|
||
name: String,
|
||
version: Option<String>,
|
||
latest_version: Option<String>, // 新增字段:最新版本
|
||
error: Option<String>,
|
||
/// 工具运行环境: "windows", "wsl", "macos", "linux", "unknown"
|
||
env_type: String,
|
||
/// 当 env_type 为 "wsl" 时,返回该工具绑定的 WSL distro(用于按 distro 探测 shells)
|
||
wsl_distro: Option<String>,
|
||
}
|
||
|
||
const VALID_TOOLS: [&str; 4] = ["claude", "codex", "gemini", "opencode"];
|
||
|
||
#[derive(Debug, Clone, serde::Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct WslShellPreferenceInput {
|
||
#[serde(default)]
|
||
pub wsl_shell: Option<String>,
|
||
#[serde(default)]
|
||
pub wsl_shell_flag: Option<String>,
|
||
}
|
||
|
||
// Keep platform-specific env detection in one place to avoid repeating cfg blocks.
|
||
#[cfg(target_os = "windows")]
|
||
fn tool_env_type_and_wsl_distro(tool: &str) -> (String, Option<String>) {
|
||
if let Some(distro) = wsl_distro_for_tool(tool) {
|
||
("wsl".to_string(), Some(distro))
|
||
} else {
|
||
("windows".to_string(), None)
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||
("macos".to_string(), None)
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||
("linux".to_string(), None)
|
||
}
|
||
|
||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||
("unknown".to_string(), None)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_tool_versions(
|
||
tools: Option<Vec<String>>,
|
||
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
|
||
) -> Result<Vec<ToolVersion>, String> {
|
||
// Windows: completely disable tool version detection to prevent
|
||
// accidentally launching apps (e.g. Claude Code) via protocol handlers.
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
let _ = (tools, wsl_shell_by_tool);
|
||
return Ok(Vec::new());
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
|
||
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
|
||
VALID_TOOLS
|
||
.iter()
|
||
.copied()
|
||
.filter(|t| set.contains(t))
|
||
.collect()
|
||
} else {
|
||
VALID_TOOLS.to_vec()
|
||
};
|
||
let mut results = Vec::new();
|
||
|
||
for tool in requested {
|
||
let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));
|
||
let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());
|
||
let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());
|
||
|
||
results.push(
|
||
get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await,
|
||
);
|
||
}
|
||
|
||
Ok(results)
|
||
}
|
||
}
|
||
|
||
/// 获取单个工具的版本信息(内部实现)
|
||
async fn get_single_tool_version_impl(
|
||
tool: &str,
|
||
wsl_shell: Option<&str>,
|
||
wsl_shell_flag: Option<&str>,
|
||
) -> ToolVersion {
|
||
debug_assert!(
|
||
VALID_TOOLS.contains(&tool),
|
||
"unexpected tool name in get_single_tool_version_impl: {tool}"
|
||
);
|
||
|
||
// 判断该工具的运行环境 & WSL distro(如有)
|
||
let (env_type, wsl_distro) = tool_env_type_and_wsl_distro(tool);
|
||
|
||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||
let client = crate::proxy::http_client::get();
|
||
|
||
// 1. 获取本地版本
|
||
let (local_version, local_error) = if let Some(distro) = wsl_distro.as_deref() {
|
||
try_get_version_wsl(tool, distro, wsl_shell, wsl_shell_flag)
|
||
} else {
|
||
let direct_result = try_get_version(tool);
|
||
if direct_result.0.is_some() {
|
||
direct_result
|
||
} else {
|
||
scan_cli_version(tool)
|
||
}
|
||
};
|
||
|
||
// 2. 获取远程最新版本
|
||
let latest_version = match tool {
|
||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||
_ => None,
|
||
};
|
||
|
||
ToolVersion {
|
||
name: tool.to_string(),
|
||
version: local_version,
|
||
latest_version,
|
||
error: local_error,
|
||
env_type,
|
||
wsl_distro,
|
||
}
|
||
}
|
||
|
||
/// Helper function to fetch latest version from npm registry
|
||
async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {
|
||
let url = format!("https://registry.npmjs.org/{package}");
|
||
match client.get(&url).send().await {
|
||
Ok(resp) => {
|
||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||
json.get("dist-tags")
|
||
.and_then(|tags| tags.get("latest"))
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
Err(_) => None,
|
||
}
|
||
}
|
||
|
||
/// Helper function to fetch latest version from GitHub releases
|
||
async fn fetch_github_latest_version(client: &reqwest::Client, repo: &str) -> Option<String> {
|
||
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
|
||
match client
|
||
.get(&url)
|
||
.header("User-Agent", "cc-switch")
|
||
.header("Accept", "application/vnd.github+json")
|
||
.send()
|
||
.await
|
||
{
|
||
Ok(resp) => {
|
||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||
json.get("tag_name")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.strip_prefix('v').unwrap_or(s).to_string())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
Err(_) => None,
|
||
}
|
||
}
|
||
|
||
/// 预编译的版本号正则表达式
|
||
static VERSION_RE: Lazy<Regex> =
|
||
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
|
||
|
||
/// 从版本输出中提取纯版本号
|
||
fn extract_version(raw: &str) -> String {
|
||
VERSION_RE
|
||
.find(raw)
|
||
.map(|m| m.as_str().to_string())
|
||
.unwrap_or_else(|| raw.to_string())
|
||
}
|
||
|
||
/// 尝试直接执行命令获取版本
|
||
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||
use std::process::Command;
|
||
|
||
#[cfg(target_os = "windows")]
|
||
let output = {
|
||
Command::new("cmd")
|
||
.args(["/C", &format!("{tool} --version")])
|
||
.creation_flags(CREATE_NO_WINDOW)
|
||
.output()
|
||
};
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
let output = {
|
||
Command::new("sh")
|
||
.arg("-c")
|
||
.arg(format!("{tool} --version"))
|
||
.output()
|
||
};
|
||
|
||
match output {
|
||
Ok(out) => {
|
||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||
if out.status.success() {
|
||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||
if raw.is_empty() {
|
||
(None, Some("not installed or not executable".to_string()))
|
||
} else {
|
||
(Some(extract_version(raw)), None)
|
||
}
|
||
} else {
|
||
let err = if stderr.is_empty() { stdout } else { stderr };
|
||
(
|
||
None,
|
||
Some(if err.is_empty() {
|
||
"not installed or not executable".to_string()
|
||
} else {
|
||
err
|
||
}),
|
||
)
|
||
}
|
||
}
|
||
Err(e) => (None, Some(e.to_string())),
|
||
}
|
||
}
|
||
|
||
/// 校验 WSL 发行版名称是否合法
|
||
/// WSL 发行版名称只允许字母、数字、连字符和下划线
|
||
#[cfg(target_os = "windows")]
|
||
fn is_valid_wsl_distro_name(name: &str) -> bool {
|
||
!name.is_empty()
|
||
&& name.len() <= 64
|
||
&& name
|
||
.chars()
|
||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||
}
|
||
|
||
/// Validate that the given shell name is one of the allowed shells.
|
||
#[cfg(target_os = "windows")]
|
||
fn is_valid_shell(shell: &str) -> bool {
|
||
matches!(
|
||
shell.rsplit('/').next().unwrap_or(shell),
|
||
"sh" | "bash" | "zsh" | "fish" | "dash"
|
||
)
|
||
}
|
||
|
||
/// Validate that the given shell flag is one of the allowed flags.
|
||
#[cfg(target_os = "windows")]
|
||
fn is_valid_shell_flag(flag: &str) -> bool {
|
||
matches!(flag, "-c" | "-lc" | "-lic")
|
||
}
|
||
|
||
/// Return the default invocation flag for the given shell.
|
||
#[cfg(target_os = "windows")]
|
||
fn default_flag_for_shell(shell: &str) -> &'static str {
|
||
match shell.rsplit('/').next().unwrap_or(shell) {
|
||
"dash" | "sh" => "-c",
|
||
"fish" => "-lc",
|
||
_ => "-lic",
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
fn try_get_version_wsl(
|
||
tool: &str,
|
||
distro: &str,
|
||
force_shell: Option<&str>,
|
||
force_shell_flag: Option<&str>,
|
||
) -> (Option<String>, Option<String>) {
|
||
use std::process::Command;
|
||
|
||
// 防御性断言:tool 只能是预定义的值
|
||
debug_assert!(
|
||
["claude", "codex", "gemini", "opencode"].contains(&tool),
|
||
"unexpected tool name: {tool}"
|
||
);
|
||
|
||
// 校验 distro 名称,防止命令注入
|
||
if !is_valid_wsl_distro_name(distro) {
|
||
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
||
}
|
||
|
||
// 构建 Shell 脚本检测逻辑
|
||
let (shell, flag, cmd) = if let Some(shell) = force_shell {
|
||
// Defensive validation: never allow an arbitrary executable name here.
|
||
if !is_valid_shell(shell) {
|
||
return (None, Some(format!("[WSL:{distro}] invalid shell: {shell}")));
|
||
}
|
||
let shell = shell.rsplit('/').next().unwrap_or(shell);
|
||
let flag = if let Some(flag) = force_shell_flag {
|
||
if !is_valid_shell_flag(flag) {
|
||
return (
|
||
None,
|
||
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
|
||
);
|
||
}
|
||
flag
|
||
} else {
|
||
default_flag_for_shell(shell)
|
||
};
|
||
|
||
(shell.to_string(), flag, format!("{tool} --version"))
|
||
} else {
|
||
let cmd = if let Some(flag) = force_shell_flag {
|
||
if !is_valid_shell_flag(flag) {
|
||
return (
|
||
None,
|
||
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
|
||
);
|
||
}
|
||
format!("\"${{SHELL:-sh}}\" {flag} '{tool} --version'")
|
||
} else {
|
||
// 兜底:自动尝试 -lic, -lc, -c
|
||
format!(
|
||
"\"${{SHELL:-sh}}\" -lic '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -lc '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -c '{tool} --version'"
|
||
)
|
||
};
|
||
|
||
("sh".to_string(), "-c", cmd)
|
||
};
|
||
|
||
let output = Command::new("wsl.exe")
|
||
.args(["-d", distro, "--", &shell, flag, &cmd])
|
||
.creation_flags(CREATE_NO_WINDOW)
|
||
.output();
|
||
|
||
match output {
|
||
Ok(out) => {
|
||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||
if out.status.success() {
|
||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||
if raw.is_empty() {
|
||
(
|
||
None,
|
||
Some(format!("[WSL:{distro}] not installed or not executable")),
|
||
)
|
||
} else {
|
||
(Some(extract_version(raw)), None)
|
||
}
|
||
} else {
|
||
let err = if stderr.is_empty() { stdout } else { stderr };
|
||
(
|
||
None,
|
||
Some(format!(
|
||
"[WSL:{distro}] {}",
|
||
if err.is_empty() {
|
||
"not installed or not executable".to_string()
|
||
} else {
|
||
err
|
||
}
|
||
)),
|
||
)
|
||
}
|
||
}
|
||
Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))),
|
||
}
|
||
}
|
||
|
||
/// 非 Windows 平台的 WSL 版本检测存根
|
||
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
||
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
||
#[cfg(not(target_os = "windows"))]
|
||
fn try_get_version_wsl(
|
||
_tool: &str,
|
||
_distro: &str,
|
||
_force_shell: Option<&str>,
|
||
_force_shell_flag: Option<&str>,
|
||
) -> (Option<String>, Option<String>) {
|
||
(
|
||
None,
|
||
Some("WSL check not supported on this platform".to_string()),
|
||
)
|
||
}
|
||
|
||
fn push_unique_path(paths: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
|
||
if path.as_os_str().is_empty() {
|
||
return;
|
||
}
|
||
|
||
if !paths.iter().any(|existing| existing == &path) {
|
||
paths.push(path);
|
||
}
|
||
}
|
||
|
||
fn push_env_single_dir(paths: &mut Vec<std::path::PathBuf>, value: Option<std::ffi::OsString>) {
|
||
if let Some(raw) = value {
|
||
push_unique_path(paths, std::path::PathBuf::from(raw));
|
||
}
|
||
}
|
||
|
||
fn extend_from_path_list(
|
||
paths: &mut Vec<std::path::PathBuf>,
|
||
value: Option<std::ffi::OsString>,
|
||
suffix: Option<&str>,
|
||
) {
|
||
if let Some(raw) = value {
|
||
for p in std::env::split_paths(&raw) {
|
||
let dir = match suffix {
|
||
Some(s) => p.join(s),
|
||
None => p,
|
||
};
|
||
push_unique_path(paths, dir);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README):
|
||
/// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin
|
||
/// 额外扫描 Bun 默认全局安装路径(~/.bun/bin)
|
||
/// 和 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
|
||
fn opencode_extra_search_paths(
|
||
home: &Path,
|
||
opencode_install_dir: Option<std::ffi::OsString>,
|
||
xdg_bin_dir: Option<std::ffi::OsString>,
|
||
gopath: Option<std::ffi::OsString>,
|
||
) -> Vec<std::path::PathBuf> {
|
||
let mut paths = Vec::new();
|
||
|
||
push_env_single_dir(&mut paths, opencode_install_dir);
|
||
push_env_single_dir(&mut paths, xdg_bin_dir);
|
||
|
||
if !home.as_os_str().is_empty() {
|
||
push_unique_path(&mut paths, home.join("bin"));
|
||
push_unique_path(&mut paths, home.join(".opencode").join("bin"));
|
||
push_unique_path(&mut paths, home.join(".bun").join("bin"));
|
||
push_unique_path(&mut paths, home.join("go").join("bin"));
|
||
}
|
||
|
||
extend_from_path_list(&mut paths, gopath, Some("bin"));
|
||
|
||
paths
|
||
}
|
||
|
||
fn tool_executable_candidates(tool: &str, dir: &Path) -> Vec<std::path::PathBuf> {
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
vec![
|
||
dir.join(format!("{tool}.cmd")),
|
||
dir.join(format!("{tool}.exe")),
|
||
dir.join(tool),
|
||
]
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
vec![dir.join(tool)]
|
||
}
|
||
}
|
||
|
||
/// 扫描常见路径查找 CLI
|
||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||
use std::process::Command;
|
||
|
||
let home = dirs::home_dir().unwrap_or_default();
|
||
|
||
// 常见的安装路径(原生安装优先)
|
||
let mut search_paths: Vec<std::path::PathBuf> = Vec::new();
|
||
if !home.as_os_str().is_empty() {
|
||
push_unique_path(&mut search_paths, home.join(".local/bin"));
|
||
push_unique_path(&mut search_paths, home.join(".npm-global/bin"));
|
||
push_unique_path(&mut search_paths, home.join("n/bin"));
|
||
push_unique_path(&mut search_paths, home.join(".volta/bin"));
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
push_unique_path(
|
||
&mut search_paths,
|
||
std::path::PathBuf::from("/opt/homebrew/bin"),
|
||
);
|
||
push_unique_path(
|
||
&mut search_paths,
|
||
std::path::PathBuf::from("/usr/local/bin"),
|
||
);
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
push_unique_path(
|
||
&mut search_paths,
|
||
std::path::PathBuf::from("/usr/local/bin"),
|
||
);
|
||
push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/bin"));
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
if let Some(appdata) = dirs::data_dir() {
|
||
push_unique_path(&mut search_paths, appdata.join("npm"));
|
||
}
|
||
push_unique_path(
|
||
&mut search_paths,
|
||
std::path::PathBuf::from("C:\\Program Files\\nodejs"),
|
||
);
|
||
}
|
||
|
||
let fnm_base = home.join(".local/state/fnm_multishells");
|
||
if fnm_base.exists() {
|
||
if let Ok(entries) = std::fs::read_dir(&fnm_base) {
|
||
for entry in entries.flatten() {
|
||
let bin_path = entry.path().join("bin");
|
||
if bin_path.exists() {
|
||
push_unique_path(&mut search_paths, bin_path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let nvm_base = home.join(".nvm/versions/node");
|
||
if nvm_base.exists() {
|
||
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
|
||
for entry in entries.flatten() {
|
||
let bin_path = entry.path().join("bin");
|
||
if bin_path.exists() {
|
||
push_unique_path(&mut search_paths, bin_path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if tool == "opencode" {
|
||
let extra_paths = opencode_extra_search_paths(
|
||
&home,
|
||
std::env::var_os("OPENCODE_INSTALL_DIR"),
|
||
std::env::var_os("XDG_BIN_DIR"),
|
||
std::env::var_os("GOPATH"),
|
||
);
|
||
|
||
for path in extra_paths {
|
||
push_unique_path(&mut search_paths, path);
|
||
}
|
||
}
|
||
|
||
let current_path = std::env::var("PATH").unwrap_or_default();
|
||
|
||
for path in &search_paths {
|
||
#[cfg(target_os = "windows")]
|
||
let new_path = format!("{};{}", path.display(), current_path);
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
let new_path = format!("{}:{}", path.display(), current_path);
|
||
|
||
for tool_path in tool_executable_candidates(tool, path) {
|
||
if !tool_path.exists() {
|
||
continue;
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
let output = {
|
||
Command::new("cmd")
|
||
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
|
||
.env("PATH", &new_path)
|
||
.creation_flags(CREATE_NO_WINDOW)
|
||
.output()
|
||
};
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
let output = {
|
||
Command::new(&tool_path)
|
||
.arg("--version")
|
||
.env("PATH", &new_path)
|
||
.output()
|
||
};
|
||
|
||
if let Ok(out) = output {
|
||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||
if out.status.success() {
|
||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||
if !raw.is_empty() {
|
||
return (Some(extract_version(raw)), None);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
(None, Some("not installed or not executable".to_string()))
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||
let override_dir = match tool {
|
||
"claude" => crate::settings::get_claude_override_dir(),
|
||
"codex" => crate::settings::get_codex_override_dir(),
|
||
"gemini" => crate::settings::get_gemini_override_dir(),
|
||
"opencode" => crate::settings::get_opencode_override_dir(),
|
||
_ => None,
|
||
}?;
|
||
|
||
wsl_distro_from_path(&override_dir)
|
||
}
|
||
|
||
/// 从 UNC 路径中提取 WSL 发行版名称
|
||
/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式
|
||
#[cfg(target_os = "windows")]
|
||
fn wsl_distro_from_path(path: &Path) -> Option<String> {
|
||
use std::path::{Component, Prefix};
|
||
let Some(Component::Prefix(prefix)) = path.components().next() else {
|
||
return None;
|
||
};
|
||
match prefix.kind() {
|
||
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
|
||
let server_name = server.to_string_lossy();
|
||
if server_name.eq_ignore_ascii_case("wsl$")
|
||
|| server_name.eq_ignore_ascii_case("wsl.localhost")
|
||
{
|
||
let distro = share.to_string_lossy().to_string();
|
||
if !distro.is_empty() {
|
||
return Some(distro);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// 打开指定提供商的终端
|
||
///
|
||
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
|
||
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
|
||
#[allow(non_snake_case)]
|
||
#[tauri::command]
|
||
pub async fn open_provider_terminal(
|
||
state: State<'_, crate::store::AppState>,
|
||
app: String,
|
||
#[allow(non_snake_case)] providerId: String,
|
||
cwd: Option<String>,
|
||
) -> Result<bool, String> {
|
||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||
let launch_cwd = resolve_launch_cwd(cwd)?;
|
||
|
||
// 获取提供商配置
|
||
let providers = ProviderService::list(state.inner(), app_type.clone())
|
||
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
|
||
|
||
let provider = providers
|
||
.get(&providerId)
|
||
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
|
||
|
||
// 从提供商配置中提取环境变量
|
||
let config = &provider.settings_config;
|
||
let env_vars = extract_env_vars_from_config(config, &app_type);
|
||
|
||
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
||
launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
|
||
.map_err(|e| format!("启动终端失败: {e}"))?;
|
||
|
||
Ok(true)
|
||
}
|
||
|
||
/// 从提供商配置中提取环境变量
|
||
fn extract_env_vars_from_config(
|
||
config: &serde_json::Value,
|
||
app_type: &AppType,
|
||
) -> Vec<(String, String)> {
|
||
let mut env_vars = Vec::new();
|
||
|
||
let Some(obj) = config.as_object() else {
|
||
return env_vars;
|
||
};
|
||
|
||
// 处理 env 字段(Claude/Gemini 通用)
|
||
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
|
||
for (key, value) in env {
|
||
if let Some(str_val) = value.as_str() {
|
||
env_vars.push((key.clone(), str_val.to_string()));
|
||
}
|
||
}
|
||
|
||
// 处理 base_url: 根据应用类型添加对应的环境变量
|
||
let base_url_key = match app_type {
|
||
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
|
||
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
|
||
_ => None,
|
||
};
|
||
|
||
if let Some(key) = base_url_key {
|
||
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
|
||
env_vars.push((key.to_string(), url_str.to_string()));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
|
||
if *app_type == AppType::Codex {
|
||
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
|
||
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
|
||
}
|
||
}
|
||
|
||
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
|
||
if *app_type == AppType::Gemini {
|
||
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
|
||
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
|
||
}
|
||
}
|
||
|
||
env_vars
|
||
}
|
||
|
||
fn resolve_launch_cwd(cwd: Option<String>) -> Result<Option<PathBuf>, String> {
|
||
let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else {
|
||
return Ok(None);
|
||
};
|
||
|
||
if raw_path.contains('\n') || raw_path.contains('\r') {
|
||
return Err("目录路径包含非法换行符".to_string());
|
||
}
|
||
|
||
let path = Path::new(&raw_path);
|
||
if !path.exists() {
|
||
return Err(format!("目录不存在: {raw_path}"));
|
||
}
|
||
|
||
let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?;
|
||
if !resolved.is_dir() {
|
||
return Err(format!("选择的路径不是文件夹: {}", resolved.display()));
|
||
}
|
||
|
||
// Strip Windows extended-length prefix that canonicalize produces,
|
||
// as it can break batch scripts and other shell commands.
|
||
// Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths.
|
||
#[cfg(target_os = "windows")]
|
||
let resolved = {
|
||
let s = resolved.to_string_lossy();
|
||
if let Some(unc) = s.strip_prefix(r"\\?\UNC\") {
|
||
PathBuf::from(format!(r"\\{unc}"))
|
||
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||
PathBuf::from(stripped)
|
||
} else {
|
||
resolved
|
||
}
|
||
};
|
||
|
||
Ok(Some(resolved))
|
||
}
|
||
|
||
/// 创建临时配置文件并启动 claude 终端
|
||
/// 使用 --settings 参数传入提供商特定的 API 配置
|
||
fn launch_terminal_with_env(
|
||
env_vars: Vec<(String, String)>,
|
||
provider_id: &str,
|
||
cwd: Option<&Path>,
|
||
) -> Result<(), String> {
|
||
let temp_dir = std::env::temp_dir();
|
||
let config_file = temp_dir.join(format!(
|
||
"claude_{}_{}.json",
|
||
provider_id,
|
||
std::process::id()
|
||
));
|
||
|
||
// 创建并写入配置文件
|
||
write_claude_config(&config_file, &env_vars)?;
|
||
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
launch_macos_terminal(&config_file, cwd)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
launch_linux_terminal(&config_file, cwd)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
|
||
return Ok(());
|
||
}
|
||
|
||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||
Err("不支持的操作系统".to_string())
|
||
}
|
||
|
||
/// 写入 claude 配置文件
|
||
fn write_claude_config(
|
||
config_file: &std::path::Path,
|
||
env_vars: &[(String, String)],
|
||
) -> Result<(), String> {
|
||
let mut config_obj = serde_json::Map::new();
|
||
let mut env_obj = serde_json::Map::new();
|
||
|
||
for (key, value) in env_vars {
|
||
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
|
||
}
|
||
|
||
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
|
||
|
||
let config_json =
|
||
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
|
||
|
||
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
||
}
|
||
|
||
/// macOS: 根据用户首选终端启动
|
||
#[cfg(target_os = "macos")]
|
||
fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let preferred = crate::settings::get_preferred_terminal();
|
||
let terminal = preferred.as_deref().unwrap_or("terminal");
|
||
|
||
let temp_dir = std::env::temp_dir();
|
||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||
let config_path = config_file.to_string_lossy();
|
||
let cd_command = build_shell_cd_command(cwd);
|
||
|
||
// Write the shell script to a temp file
|
||
let script_content = format!(
|
||
r#"#!/bin/bash
|
||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||
{cd_command}
|
||
echo "Using provider-specific claude config:"
|
||
echo "{config_path}"
|
||
claude --settings "{config_path}"
|
||
exec bash --norc --noprofile
|
||
"#,
|
||
config_path = config_path,
|
||
script_file = script_file.display(),
|
||
cd_command = cd_command,
|
||
);
|
||
|
||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||
|
||
// Make script executable
|
||
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||
|
||
// Try the preferred terminal first, fall back to Terminal.app if it fails
|
||
// Note: Kitty doesn't need the -e flag, others do
|
||
let result = match terminal {
|
||
"iterm2" => launch_macos_iterm2(&script_file),
|
||
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
|
||
"kitty" => launch_macos_open_app("kitty", &script_file, false),
|
||
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
|
||
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
|
||
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
|
||
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
|
||
};
|
||
|
||
// If preferred terminal fails and it's not the default, try Terminal.app as fallback
|
||
if result.is_err() && terminal != "terminal" {
|
||
log::warn!(
|
||
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
|
||
terminal,
|
||
result.as_ref().err()
|
||
);
|
||
return launch_macos_terminal_app(&script_file);
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// macOS: Terminal.app
|
||
#[cfg(target_os = "macos")]
|
||
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
|
||
use std::process::Command;
|
||
|
||
let applescript = format!(
|
||
r#"tell application "Terminal"
|
||
activate
|
||
do script "bash '{}'"
|
||
end tell"#,
|
||
script_file.display()
|
||
);
|
||
|
||
let output = Command::new("osascript")
|
||
.arg("-e")
|
||
.arg(&applescript)
|
||
.output()
|
||
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
return Err(format!(
|
||
"Terminal.app 执行失败 (exit code: {:?}): {}",
|
||
output.status.code(),
|
||
stderr
|
||
));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// macOS: iTerm2
|
||
#[cfg(target_os = "macos")]
|
||
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
|
||
use std::process::Command;
|
||
|
||
let applescript = format!(
|
||
r#"tell application "iTerm"
|
||
activate
|
||
tell current window
|
||
create tab with default profile
|
||
tell current session
|
||
write text "bash '{}'"
|
||
end tell
|
||
end tell
|
||
end tell"#,
|
||
script_file.display()
|
||
);
|
||
|
||
let output = Command::new("osascript")
|
||
.arg("-e")
|
||
.arg(&applescript)
|
||
.output()
|
||
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
return Err(format!(
|
||
"iTerm2 执行失败 (exit code: {:?}): {}",
|
||
output.status.code(),
|
||
stderr
|
||
));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// macOS: 使用 open -a 启动支持 --args 参数的终端(Alacritty/Kitty/Ghostty)
|
||
#[cfg(target_os = "macos")]
|
||
fn launch_macos_open_app(
|
||
app_name: &str,
|
||
script_file: &std::path::Path,
|
||
use_e_flag: bool,
|
||
) -> Result<(), String> {
|
||
use std::process::Command;
|
||
|
||
let mut cmd = Command::new("open");
|
||
cmd.arg("-a").arg(app_name).arg("--args");
|
||
|
||
if use_e_flag {
|
||
cmd.arg("-e");
|
||
}
|
||
cmd.arg("bash").arg(script_file);
|
||
|
||
let output = cmd
|
||
.output()
|
||
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
return Err(format!(
|
||
"{} 启动失败 (exit code: {:?}): {}",
|
||
app_name,
|
||
output.status.code(),
|
||
stderr
|
||
));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Linux: 根据用户首选终端启动
|
||
#[cfg(target_os = "linux")]
|
||
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
use std::process::Command;
|
||
|
||
let preferred = crate::settings::get_preferred_terminal();
|
||
|
||
// Default terminal list with their arguments
|
||
let default_terminals = [
|
||
("gnome-terminal", vec!["--"]),
|
||
("konsole", vec!["-e"]),
|
||
("xfce4-terminal", vec!["-e"]),
|
||
("mate-terminal", vec!["--"]),
|
||
("lxterminal", vec!["-e"]),
|
||
("alacritty", vec!["-e"]),
|
||
("kitty", vec!["-e"]),
|
||
("ghostty", vec!["-e"]),
|
||
];
|
||
|
||
// Create temp script file
|
||
let temp_dir = std::env::temp_dir();
|
||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||
let config_path = config_file.to_string_lossy();
|
||
let cd_command = build_shell_cd_command(cwd);
|
||
|
||
let script_content = format!(
|
||
r#"#!/bin/bash
|
||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||
{cd_command}
|
||
echo "Using provider-specific claude config:"
|
||
echo "{config_path}"
|
||
claude --settings "{config_path}"
|
||
exec bash --norc --noprofile
|
||
"#,
|
||
config_path = config_path,
|
||
script_file = script_file.display(),
|
||
cd_command = cd_command,
|
||
);
|
||
|
||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||
|
||
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||
|
||
// Build terminal list: preferred terminal first (if specified), then defaults
|
||
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
|
||
// Find the preferred terminal's args from default list
|
||
let pref_args = default_terminals
|
||
.iter()
|
||
.find(|(name, _)| *name == pref.as_str())
|
||
.map(|(_, args)| args.to_vec())
|
||
.unwrap_or_else(|| vec!["-e"]); // Default args for unknown terminals
|
||
|
||
let mut list = vec![(pref.as_str(), pref_args)];
|
||
// Add remaining terminals as fallbacks
|
||
for (name, args) in &default_terminals {
|
||
if *name != pref.as_str() {
|
||
list.push((*name, args.to_vec()));
|
||
}
|
||
}
|
||
list
|
||
} else {
|
||
default_terminals
|
||
.iter()
|
||
.map(|(name, args)| (*name, args.to_vec()))
|
||
.collect()
|
||
};
|
||
|
||
let mut last_error = String::from("未找到可用的终端");
|
||
|
||
for (terminal, args) in terminals_to_try {
|
||
// Check if terminal exists in common paths
|
||
let terminal_exists = std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
||
|| std::path::Path::new(&format!("/usr/local/bin/{}", terminal)).exists()
|
||
|| which_command(terminal);
|
||
|
||
if terminal_exists {
|
||
let result = Command::new(terminal)
|
||
.args(&args)
|
||
.arg("bash")
|
||
.arg(script_file.to_string_lossy().as_ref())
|
||
.spawn();
|
||
|
||
match result {
|
||
Ok(_) => return Ok(()),
|
||
Err(e) => {
|
||
last_error = format!("执行 {} 失败: {}", terminal, e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clean up on failure
|
||
let _ = std::fs::remove_file(&script_file);
|
||
let _ = std::fs::remove_file(config_file);
|
||
Err(last_error)
|
||
}
|
||
|
||
/// Check if a command exists using `which`
|
||
#[cfg(target_os = "linux")]
|
||
fn which_command(cmd: &str) -> bool {
|
||
use std::process::Command;
|
||
Command::new("which")
|
||
.arg(cmd)
|
||
.output()
|
||
.map(|o| o.status.success())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Windows: 根据用户首选终端启动
|
||
#[cfg(target_os = "windows")]
|
||
fn launch_windows_terminal(
|
||
temp_dir: &std::path::Path,
|
||
config_file: &std::path::Path,
|
||
cwd: Option<&Path>,
|
||
) -> Result<(), String> {
|
||
let preferred = crate::settings::get_preferred_terminal();
|
||
let terminal = preferred.as_deref().unwrap_or("cmd");
|
||
|
||
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||
let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy());
|
||
let cwd_command = build_windows_cwd_command(cwd);
|
||
|
||
let content = format!(
|
||
"@echo off
|
||
{cwd_command}
|
||
echo Using provider-specific claude config:
|
||
echo {}
|
||
claude --settings \"{}\"
|
||
del \"{}\" >nul 2>&1
|
||
del \"%~f0\" >nul 2>&1
|
||
",
|
||
config_path_for_batch,
|
||
config_path_for_batch,
|
||
config_path_for_batch,
|
||
cwd_command = cwd_command,
|
||
);
|
||
|
||
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||
|
||
let bat_path = bat_file.to_string_lossy();
|
||
let ps_cmd = format!("& '{}'", bat_path);
|
||
|
||
// Try the preferred terminal first
|
||
let result = match terminal {
|
||
"powershell" => run_windows_start_command(
|
||
&["powershell", "-NoExit", "-Command", &ps_cmd],
|
||
"PowerShell",
|
||
),
|
||
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
|
||
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
|
||
};
|
||
|
||
// If preferred terminal fails and it's not the default, try cmd as fallback
|
||
if result.is_err() && terminal != "cmd" {
|
||
log::warn!(
|
||
"首选终端 {} 启动失败,回退到 cmd: {:?}",
|
||
terminal,
|
||
result.as_ref().err()
|
||
);
|
||
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
fn build_shell_cd_command(cwd: Option<&Path>) -> String {
|
||
cwd.map(|dir| {
|
||
format!(
|
||
"cd {} || exit 1\n",
|
||
shell_single_quote(&dir.to_string_lossy())
|
||
)
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn shell_single_quote(value: &str) -> String {
|
||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||
}
|
||
|
||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||
fn is_windows_unc_path(path: &str) -> bool {
|
||
path.starts_with(r"\\")
|
||
}
|
||
|
||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||
fn build_windows_cwd_command_str(path: &str) -> String {
|
||
let escaped = escape_windows_batch_value(path);
|
||
|
||
if is_windows_unc_path(path) {
|
||
// `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first.
|
||
format!("pushd \"{escaped}\" || exit /b 1\r\n")
|
||
} else {
|
||
format!("cd /d \"{escaped}\" || exit /b 1\r\n")
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
fn build_windows_cwd_command(cwd: Option<&Path>) -> String {
|
||
cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy()))
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||
fn escape_windows_batch_value(value: &str) -> String {
|
||
value
|
||
.replace('^', "^^")
|
||
.replace('%', "%%")
|
||
.replace('&', "^&")
|
||
.replace('|', "^|")
|
||
.replace('<', "^<")
|
||
.replace('>', "^>")
|
||
.replace('(', "^(")
|
||
.replace(')', "^)")
|
||
}
|
||
/// Windows: Run a start command with common error handling
|
||
#[cfg(target_os = "windows")]
|
||
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
|
||
use std::process::Command;
|
||
|
||
let mut full_args = vec!["/C", "start"];
|
||
full_args.extend(args);
|
||
|
||
let output = Command::new("cmd")
|
||
.args(&full_args)
|
||
.creation_flags(CREATE_NO_WINDOW)
|
||
.output()
|
||
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
return Err(format!(
|
||
"{} 启动失败 (exit code: {:?}): {}",
|
||
terminal_name,
|
||
output.status.code(),
|
||
stderr
|
||
));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 设置窗口主题(Windows/macOS 标题栏颜色)
|
||
/// theme: "dark" | "light" | "system"
|
||
#[tauri::command]
|
||
pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<(), String> {
|
||
use tauri::Theme;
|
||
|
||
let tauri_theme = match theme.as_str() {
|
||
"dark" => Some(Theme::Dark),
|
||
"light" => Some(Theme::Light),
|
||
_ => None, // system default
|
||
};
|
||
|
||
window.set_theme(tauri_theme).map_err(|e| e.to_string())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::path::PathBuf;
|
||
|
||
#[test]
|
||
fn test_extract_version() {
|
||
assert_eq!(extract_version("claude 1.0.20"), "1.0.20");
|
||
assert_eq!(extract_version("v2.3.4-beta.1"), "2.3.4-beta.1");
|
||
assert_eq!(extract_version("no version here"), "no version here");
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
mod wsl_helpers {
|
||
use super::super::*;
|
||
|
||
#[test]
|
||
fn test_is_valid_shell() {
|
||
assert!(is_valid_shell("bash"));
|
||
assert!(is_valid_shell("zsh"));
|
||
assert!(is_valid_shell("sh"));
|
||
assert!(is_valid_shell("fish"));
|
||
assert!(is_valid_shell("dash"));
|
||
assert!(is_valid_shell("/usr/bin/bash"));
|
||
assert!(is_valid_shell("/bin/zsh"));
|
||
assert!(!is_valid_shell("powershell"));
|
||
assert!(!is_valid_shell("cmd"));
|
||
assert!(!is_valid_shell(""));
|
||
}
|
||
|
||
#[test]
|
||
fn test_is_valid_shell_flag() {
|
||
assert!(is_valid_shell_flag("-c"));
|
||
assert!(is_valid_shell_flag("-lc"));
|
||
assert!(is_valid_shell_flag("-lic"));
|
||
assert!(!is_valid_shell_flag("-x"));
|
||
assert!(!is_valid_shell_flag(""));
|
||
assert!(!is_valid_shell_flag("--login"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_default_flag_for_shell() {
|
||
assert_eq!(default_flag_for_shell("sh"), "-c");
|
||
assert_eq!(default_flag_for_shell("dash"), "-c");
|
||
assert_eq!(default_flag_for_shell("/bin/dash"), "-c");
|
||
assert_eq!(default_flag_for_shell("fish"), "-lc");
|
||
assert_eq!(default_flag_for_shell("bash"), "-lic");
|
||
assert_eq!(default_flag_for_shell("zsh"), "-lic");
|
||
assert_eq!(default_flag_for_shell("/usr/bin/zsh"), "-lic");
|
||
}
|
||
|
||
#[test]
|
||
fn test_is_valid_wsl_distro_name() {
|
||
assert!(is_valid_wsl_distro_name("Ubuntu"));
|
||
assert!(is_valid_wsl_distro_name("Ubuntu-22.04"));
|
||
assert!(is_valid_wsl_distro_name("my_distro"));
|
||
assert!(!is_valid_wsl_distro_name(""));
|
||
assert!(!is_valid_wsl_distro_name("distro with spaces"));
|
||
assert!(!is_valid_wsl_distro_name(&"a".repeat(65)));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
|
||
let home = PathBuf::from("/home/tester");
|
||
let install_dir = Some(std::ffi::OsString::from("/custom/opencode/bin"));
|
||
let xdg_bin_dir = Some(std::ffi::OsString::from("/xdg/bin"));
|
||
let gopath =
|
||
std::env::join_paths([PathBuf::from("/go/path1"), PathBuf::from("/go/path2")]).ok();
|
||
|
||
let paths = opencode_extra_search_paths(&home, install_dir, xdg_bin_dir, gopath);
|
||
|
||
assert_eq!(paths[0], PathBuf::from("/custom/opencode/bin"));
|
||
assert_eq!(paths[1], PathBuf::from("/xdg/bin"));
|
||
assert!(paths.contains(&PathBuf::from("/home/tester/bin")));
|
||
assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin")));
|
||
assert!(paths.contains(&PathBuf::from("/home/tester/.bun/bin")));
|
||
assert!(paths.contains(&PathBuf::from("/home/tester/go/bin")));
|
||
assert!(paths.contains(&PathBuf::from("/go/path1/bin")));
|
||
assert!(paths.contains(&PathBuf::from("/go/path2/bin")));
|
||
}
|
||
|
||
#[test]
|
||
fn opencode_extra_search_paths_deduplicates_repeated_entries() {
|
||
let home = PathBuf::from("/home/tester");
|
||
let same_dir = Some(std::ffi::OsString::from("/same/path"));
|
||
|
||
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir, None);
|
||
|
||
let count = paths
|
||
.iter()
|
||
.filter(|path| **path == PathBuf::from("/same/path"))
|
||
.count();
|
||
assert_eq!(count, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn opencode_extra_search_paths_deduplicates_bun_default_dir() {
|
||
let home = PathBuf::from("/home/tester");
|
||
let paths = opencode_extra_search_paths(&home, None, None, None);
|
||
|
||
let count = paths
|
||
.iter()
|
||
.filter(|path| **path == PathBuf::from("/home/tester/.bun/bin"))
|
||
.count();
|
||
assert_eq!(count, 1);
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
#[test]
|
||
fn tool_executable_candidates_non_windows_uses_plain_binary_name() {
|
||
let dir = PathBuf::from("/usr/local/bin");
|
||
let candidates = tool_executable_candidates("opencode", &dir);
|
||
|
||
assert_eq!(candidates, vec![PathBuf::from("/usr/local/bin/opencode")]);
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
#[test]
|
||
fn tool_executable_candidates_windows_includes_cmd_exe_and_plain_name() {
|
||
let dir = PathBuf::from("C:\\tools");
|
||
let candidates = tool_executable_candidates("opencode", &dir);
|
||
|
||
assert_eq!(
|
||
candidates,
|
||
vec![
|
||
PathBuf::from("C:\\tools\\opencode.cmd"),
|
||
PathBuf::from("C:\\tools\\opencode.exe"),
|
||
PathBuf::from("C:\\tools\\opencode"),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_launch_cwd_accepts_existing_directory() {
|
||
let resolved =
|
||
resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned()))
|
||
.expect("temp dir should resolve")
|
||
.expect("temp dir should be present");
|
||
|
||
assert!(resolved.is_dir());
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_launch_cwd_rejects_missing_directory() {
|
||
let unique = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.expect("clock should be after epoch")
|
||
.as_nanos();
|
||
let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}"));
|
||
|
||
let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned()))
|
||
.expect_err("missing directory should fail");
|
||
|
||
assert!(error.contains("目录不存在"));
|
||
}
|
||
|
||
#[test]
|
||
fn build_shell_cd_command_quotes_spaces_and_single_quotes() {
|
||
let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien")));
|
||
|
||
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
|
||
}
|
||
|
||
#[test]
|
||
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
|
||
let command = build_windows_cwd_command_str(r"C:\work\repo");
|
||
|
||
assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n");
|
||
}
|
||
|
||
#[test]
|
||
fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() {
|
||
let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo");
|
||
|
||
assert_eq!(
|
||
command,
|
||
"pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn build_windows_cwd_command_str_escapes_batch_metacharacters() {
|
||
let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)");
|
||
|
||
assert_eq!(
|
||
command,
|
||
"pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n"
|
||
);
|
||
}
|
||
}
|