Files
CC-Switch/src-tauri/src/session_manager/terminal/mod.rs
T
tison 608ee35ecd feat: support launch warp and execute session (#2466)
* feat: support launch warp and execute session

Signed-off-by: tison <wander4096@gmail.com>

* other wires

Signed-off-by: tison <wander4096@gmail.com>

* for launch with provider

Signed-off-by: tison <wander4096@gmail.com>

* fixup indirection

Signed-off-by: tison <wander4096@gmail.com>

* clippy

Signed-off-by: tison <wander4096@gmail.com>

* address comments

Signed-off-by: tison <wander4096@gmail.com>

---------

Signed-off-by: tison <wander4096@gmail.com>
2026-04-30 10:33:50 +08:00

383 lines
11 KiB
Rust

use std::process::Command;
pub fn launch_terminal(
target: &str,
command: &str,
cwd: Option<&str>,
custom_config: Option<&str>,
) -> Result<(), String> {
if command.trim().is_empty() {
return Err("Resume command is empty".to_string());
}
if !cfg!(target_os = "macos") {
return Err("Terminal resume is only supported on macOS".to_string());
}
match target {
"terminal" => launch_macos_terminal(command, cwd),
"iTerm" | "iterm" => launch_iterm(command, cwd),
"ghostty" => launch_ghostty(command, cwd),
"kitty" => launch_kitty(command, cwd),
"wezterm" => launch_wezterm(command, cwd),
"kaku" => launch_kaku(command, cwd),
"alacritty" => launch_alacritty(command, cwd),
#[cfg(unix)]
"warp" => launch_warp(command, cwd),
"custom" => launch_custom(command, cwd, custom_config),
_ => Err(format!("Unsupported terminal target: {target}")),
}
}
fn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String> {
let full_command = build_shell_command(command, cwd);
let escaped = escape_osascript(&full_command);
let script = format!(
r#"tell application "Terminal"
activate
do script "{escaped}"
end tell"#
);
let status = Command::new("osascript")
.arg("-e")
.arg(script)
.status()
.map_err(|e| format!("Failed to launch Terminal: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Terminal command execution failed".to_string())
}
}
fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
let full_command = build_shell_command(command, cwd);
let escaped = escape_osascript(&full_command);
// iTerm2 AppleScript to create a new window and execute command
let script = format!(
r#"tell application "iTerm"
activate
create window with default profile
tell current session of current window
write text "{escaped}"
end tell
end tell"#
);
let status = Command::new("osascript")
.arg("-e")
.arg(script)
.status()
.map_err(|e| format!("Failed to launch iTerm: {e}"))?;
if status.success() {
Ok(())
} else {
Err("iTerm command execution failed".to_string())
}
}
fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let mut args = vec![
"-na".to_string(),
"Ghostty".to_string(),
"--args".to_string(),
"--quit-after-last-window-closed=true".to_string(),
];
if let Some(dir) = cwd {
if !dir.trim().is_empty() {
args.push(format!("--working-directory={dir}"));
}
}
args.push("-e".to_string());
args.push(shell);
args.push("-l".to_string());
args.push("-c".to_string());
args.push(command.to_string());
let status = Command::new("open")
.args(&args)
.status()
.map_err(|e| format!("Failed to launch Ghostty: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Ghostty. Make sure it is installed.".to_string())
}
}
fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
let full_command = build_shell_command(command, cwd);
// 获取用户默认 shell
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let status = Command::new("open")
.arg("-na")
.arg("kitty")
.arg("--args")
.arg("-e")
.arg(&shell)
.arg("-l")
.arg("-c")
.arg(&full_command)
.status()
.map_err(|e| format!("Failed to launch Kitty: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Kitty. Make sure it is installed.".to_string())
}
}
fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
// wezterm start --cwd ... -- command
// To invoke via `open`, we use `open -na "WezTerm" --args start ...`
let args = build_wezterm_compatible_args("WezTerm", command, cwd);
let status = Command::new("open")
.args(args.iter().map(String::as_str))
.status()
.map_err(|e| format!("Failed to launch WezTerm: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch WezTerm.".to_string())
}
}
fn launch_kaku(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Kaku is a WezTerm-derived terminal and keeps a compatible `start` entrypoint.
let args = build_wezterm_compatible_args("Kaku", command, cwd);
let status = Command::new("open")
.args(args.iter().map(String::as_str))
.status()
.map_err(|e| format!("Failed to launch Kaku: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Kaku.".to_string())
}
}
fn build_wezterm_compatible_args(app_name: &str, command: &str, cwd: Option<&str>) -> Vec<String> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
build_wezterm_compatible_args_with_shell(app_name, command, cwd, &shell)
}
fn build_wezterm_compatible_args_with_shell(
app_name: &str,
command: &str,
cwd: Option<&str>,
shell: &str,
) -> Vec<String> {
let full_command = build_shell_command(command, None);
let mut args = vec![
"-na".to_string(),
app_name.to_string(),
"--args".to_string(),
"start".to_string(),
];
if let Some(dir) = cwd {
args.push("--cwd".to_string());
args.push(dir.to_string());
}
// Invoke shell to run the command string (to handle pipes, etc)
args.push("--".to_string());
args.push(shell.to_string());
args.push("-c".to_string());
args.push(full_command);
args
}
#[cfg(unix)]
fn launch_warp(command: &str, cwd: Option<&str>) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let cwd = cwd.ok_or("Failed to resume session without cwd")?;
let mut script_file = tempfile::Builder::new()
.disable_cleanup(true)
.permissions(std::fs::Permissions::from_mode(0o755))
.tempfile_in(cwd)
.map_err(|e| format!("Failed to create temporary script file for launching Warp: {e}"))?;
writeln!(
&mut script_file,
r#"#!/usr/bin/env sh
rm -- "$0"
exec {command}
"#,
)
.map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?;
let mut warp_url = url::Url::parse("warp://action/new_tab").unwrap();
warp_url
.query_pairs_mut()
.append_pair("path", &script_file.path().to_string_lossy());
let warp_url = warp_url.to_string();
let status = Command::new("open")
.args(["-a", "Warp", &warp_url])
.status()
.map_err(|e| format!("Failed to launch Warp: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Warp.".to_string())
}
}
fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command
let full_command = build_shell_command(command, None);
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let mut args = vec!["-na", "Alacritty", "--args"];
if let Some(dir) = cwd {
args.push("--working-directory");
args.push(dir);
}
args.push("-e");
args.push(&shell);
args.push("-c");
args.push(&full_command);
let status = Command::new("open")
.args(&args)
.status()
.map_err(|e| format!("Failed to launch Alacritty: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Alacritty.".to_string())
}
}
fn launch_custom(
command: &str,
cwd: Option<&str>,
custom_config: Option<&str>,
) -> Result<(), String> {
let template = custom_config.ok_or("No custom terminal config provided")?;
if template.trim().is_empty() {
return Err("Custom terminal command template is empty".to_string());
}
let cmd_str = command;
let dir_str = cwd.unwrap_or(".");
let final_cmd_line = template
.replace("{command}", cmd_str)
.replace("{cwd}", dir_str);
// Execute via sh -c
let status = Command::new("sh")
.arg("-c")
.arg(&final_cmd_line)
.status()
.map_err(|e| format!("Failed to execute custom terminal launcher: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Custom terminal execution returned error code".to_string())
}
}
fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
match cwd {
Some(dir) if !dir.trim().is_empty() => {
format!("cd {} && {}", shell_escape(dir), command)
}
_ => command.to_string(),
}
}
fn shell_escape(value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn escape_osascript(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_shell_command_keeps_command_without_cwd_prefix_when_not_provided() {
assert_eq!(
build_shell_command("claude --resume abc-123", None),
"claude --resume abc-123"
);
}
#[test]
fn wezterm_compatible_terminals_use_start_and_cwd_arguments() {
let args = build_wezterm_compatible_args_with_shell(
"Kaku",
"claude --resume abc-123",
Some("/tmp/project dir"),
"/bin/zsh",
);
assert_eq!(
args,
vec![
"-na".to_string(),
"Kaku".to_string(),
"--args".to_string(),
"start".to_string(),
"--cwd".to_string(),
"/tmp/project dir".to_string(),
"--".to_string(),
"/bin/zsh".to_string(),
"-c".to_string(),
"claude --resume abc-123".to_string(),
]
);
}
#[test]
fn ghostty_uses_working_directory_arg_for_cwd() {
// cwd should be passed as --working-directory, not embedded in the shell command string
// This avoids shell expansion of special characters in directory paths
let cwd = "/tmp/project dir";
let command = "claude --resume abc-123";
// Verify build_shell_command does NOT include cwd when used in ghostty context
// (ghostty passes cwd via --working-directory flag instead)
assert_eq!(
build_shell_command(command, None),
"claude --resume abc-123"
);
// Verify shell_escape works correctly for paths with spaces
assert_eq!(shell_escape(cwd), "\"/tmp/project dir\"");
}
}