|
|
|
@@ -6,7 +6,7 @@ use crate::services::ProviderService;
|
|
|
|
|
use once_cell::sync::Lazy;
|
|
|
|
|
use regex::Regex;
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::str::FromStr;
|
|
|
|
|
use tauri::AppHandle;
|
|
|
|
|
use tauri::State;
|
|
|
|
@@ -720,8 +720,10 @@ 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())
|
|
|
|
@@ -736,7 +738,8 @@ pub async fn open_provider_terminal(
|
|
|
|
|
let env_vars = extract_env_vars_from_config(config, &app_type);
|
|
|
|
|
|
|
|
|
|
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
|
|
|
|
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
|
|
|
|
|
launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
|
|
|
|
|
.map_err(|e| format!("启动终端失败: {e}"))?;
|
|
|
|
|
|
|
|
|
|
Ok(true)
|
|
|
|
|
}
|
|
|
|
@@ -791,11 +794,49 @@ fn extract_env_vars_from_config(
|
|
|
|
|
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!(
|
|
|
|
@@ -809,19 +850,19 @@ fn launch_terminal_with_env(
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "macos")]
|
|
|
|
|
{
|
|
|
|
|
launch_macos_terminal(&config_file)?;
|
|
|
|
|
launch_macos_terminal(&config_file, cwd)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
{
|
|
|
|
|
launch_linux_terminal(&config_file)?;
|
|
|
|
|
launch_linux_terminal(&config_file, cwd)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "windows")]
|
|
|
|
|
{
|
|
|
|
|
launch_windows_terminal(&temp_dir, &config_file)?;
|
|
|
|
|
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -851,7 +892,7 @@ fn write_claude_config(
|
|
|
|
|
|
|
|
|
|
/// macOS: 根据用户首选终端启动
|
|
|
|
|
#[cfg(target_os = "macos")]
|
|
|
|
|
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
|
|
|
|
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();
|
|
|
|
@@ -860,18 +901,21 @@ fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
|
|
|
|
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()
|
|
|
|
|
script_file = script_file.display(),
|
|
|
|
|
cd_command = cd_command,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
|
|
|
@@ -1007,7 +1051,7 @@ fn launch_macos_open_app(
|
|
|
|
|
|
|
|
|
|
/// Linux: 根据用户首选终端启动
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
|
|
|
|
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
use std::process::Command;
|
|
|
|
|
|
|
|
|
@@ -1029,17 +1073,20 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
|
|
|
|
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()
|
|
|
|
|
script_file = script_file.display(),
|
|
|
|
|
cd_command = cd_command,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
|
|
|
@@ -1118,28 +1165,35 @@ fn which_command(cmd: &str) -> bool {
|
|
|
|
|
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 = config_file.to_string_lossy().replace('&', "^&");
|
|
|
|
|
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
|
|
|
|
|
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);
|
|
|
|
|
let bat_path_for_cmd = build_windows_cmd_command_str(&bat_path);
|
|
|
|
|
let ps_cmd = format!("& '{}'", escape_powershell_single_quoted(&bat_path));
|
|
|
|
|
|
|
|
|
|
// Try the preferred terminal first
|
|
|
|
|
let result = match terminal {
|
|
|
|
@@ -1147,8 +1201,10 @@ del \"%~f0\" >nul 2>&1
|
|
|
|
|
&["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
|
|
|
|
|
"wt" => {
|
|
|
|
|
run_windows_start_command(&["wt", "cmd", "/K", &bat_path_for_cmd], "Windows Terminal")
|
|
|
|
|
}
|
|
|
|
|
_ => run_windows_start_command(&["cmd", "/K", &bat_path_for_cmd], "cmd"), // "cmd" or default
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// If preferred terminal fails and it's not the default, try cmd as fallback
|
|
|
|
@@ -1158,12 +1214,81 @@ del \"%~f0\" >nul 2>&1
|
|
|
|
|
terminal,
|
|
|
|
|
result.as_ref().err()
|
|
|
|
|
);
|
|
|
|
|
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
|
|
|
|
|
return run_windows_start_command(&["cmd", "/K", &bat_path_for_cmd], "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 escape_powershell_single_quoted(value: &str) -> String {
|
|
|
|
|
value.replace('\'', "''")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
|
|
|
fn build_windows_cmd_command_str(path: &str) -> String {
|
|
|
|
|
// Avoid handing `cmd /K` a string that starts with a single quoted path:
|
|
|
|
|
// per cmd.exe parsing rules, those outer quotes may be stripped when the
|
|
|
|
|
// quoted text contains shell metacharacters. An explicit `call "..."` form
|
|
|
|
|
// keeps the command from starting with a quote while still protecting
|
|
|
|
|
// spaces and other special characters in the batch path.
|
|
|
|
|
format!("call \"{}\"", escape_windows_cmd_quoted_path(path))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
|
|
|
|
fn escape_windows_cmd_quoted_path(value: &str) -> String {
|
|
|
|
|
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> {
|
|
|
|
@@ -1338,4 +1463,76 @@ mod tests {
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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 escape_powershell_single_quoted_doubles_embedded_quotes() {
|
|
|
|
|
let escaped = escape_powershell_single_quoted(r"C:\Users\O'Brien\AppData\Local\Temp");
|
|
|
|
|
|
|
|
|
|
assert_eq!(escaped, r"C:\Users\O''Brien\AppData\Local\Temp");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn build_windows_cmd_command_str_quotes_and_escapes_metacharacters() {
|
|
|
|
|
let command = build_windows_cmd_command_str(r"C:\Users\100%&(test)\cc switch.bat");
|
|
|
|
|
|
|
|
|
|
assert_eq!(command, "call \"C:\\Users\\100%%&(test)\\cc switch.bat\"");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|