diff --git a/src-tauri/src/cmux_macos.rs b/src-tauri/src/cmux_macos.rs new file mode 100644 index 000000000..acb0af367 --- /dev/null +++ b/src-tauri/src/cmux_macos.rs @@ -0,0 +1,205 @@ +//! Launch commands in [cmux](https://www.cmux.dev/) from GUI apps. +//! On macOS: Tauri/Finder-launched processes often have a minimal `PATH` (no Homebrew), and cmux’s +//! socket may reject non-cmux-spawned clients — see error hints from [`run_in_cmux`]. + +#[cfg(target_os = "macos")] +use std::path::{Path, PathBuf}; +#[cfg(target_os = "macos")] +use std::process::{Command, Stdio}; +#[cfg(target_os = "macos")] +use std::thread; +#[cfg(target_os = "macos")] +use std::time::Duration; + +/// GUI bundle executable (`Contents/MacOS/cmux`). Starting this with `CMUX_SOCKET_MODE=allowAll` +/// is how cmux accepts control from external apps (cc-switch); `open -a` alone does not set that. +#[cfg(target_os = "macos")] +pub fn find_cmux_bundle_main_executable() -> Option { + let mut candidates: Vec = Vec::new(); + if let Some(h) = dirs::home_dir() { + candidates.push(h.join("Applications/cmux.app/Contents/MacOS/cmux")); + } + candidates.push(PathBuf::from("/Applications/cmux.app/Contents/MacOS/cmux")); + for p in candidates { + if p.is_file() { + return Some(p); + } + } + None +} + +/// Spawn cmux with socket policy that allows non-cmux-spawned processes to use the CLI (required for Tauri). +#[cfg(target_os = "macos")] +fn spawn_cmux_main_with_allow_all() -> bool { + let Some(exe) = find_cmux_bundle_main_executable() else { + return false; + }; + Command::new(&exe) + .env("CMUX_SOCKET_MODE", "allowAll") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .is_ok() +} + +/// Quit running cmux, then start it with `CMUX_SOCKET_MODE=allowAll` so cc-switch can call `cmux new-workspace` / `send`. +#[cfg(target_os = "macos")] +pub fn restart_cmux_with_allow_all() -> Result<(), String> { + let _ = Command::new("osascript") + .args([ + "-e", + r#"tell application "cmux" to if running then quit"#, + ]) + .status(); + + thread::sleep(Duration::from_millis(1600)); + + let exe = find_cmux_bundle_main_executable().ok_or_else(|| { + "找不到 cmux.app(例如 /Applications/cmux.app)。请先安装 cmux。".to_string() + })?; + + Command::new(&exe) + .env("CMUX_SOCKET_MODE", "allowAll") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("以兼容模式启动 cmux 失败: {e}"))?; + + thread::sleep(Duration::from_millis(1200)); + Ok(()) +} + +/// Bring cmux to the foreground (or start it). macOS only. +#[cfg(target_os = "macos")] +pub fn activate_cmux_app() -> Result<(), String> { + // Cold start: main binary + allowAll so socket accepts our CLI. If cmux is already running with + // stricter policy, user must use restart_cmux_with_allow_all() once. + let _ = spawn_cmux_main_with_allow_all(); + thread::sleep(Duration::from_millis(400)); + + let status = Command::new("open") + .args(["-a", "cmux"]) + .status() + .map_err(|e| format!("failed to run `open -a cmux`: {e}"))?; + if status.success() { + Ok(()) + } else { + Err("`open -a cmux` failed — is cmux installed in /Applications?".into()) + } +} + +/// Resolve the `cmux` CLI: `CMUX_CLI` env, well-known paths, then login-shell `command -v`. +#[cfg(target_os = "macos")] +pub fn resolve_cmux_cli() -> Result { + if let Ok(custom) = std::env::var("CMUX_CLI") { + let trimmed = custom.trim(); + if !trimmed.is_empty() { + let p = PathBuf::from(trimmed); + if p.is_file() { + return Ok(p); + } + return Err(format!("CMUX_CLI is set but not a file: {trimmed}")); + } + } + + let mut candidates: Vec = Vec::new(); + if let Some(h) = dirs::home_dir() { + candidates.push(h.join("Applications/cmux.app/Contents/Resources/bin/cmux")); + candidates.push(h.join("Applications/cmux.app/Contents/MacOS/cmux")); + candidates.push(h.join(".local/bin/cmux")); + } + candidates.extend([ + PathBuf::from("/Applications/cmux.app/Contents/Resources/bin/cmux"), + PathBuf::from("/Applications/cmux.app/Contents/MacOS/cmux"), + PathBuf::from("/opt/homebrew/bin/cmux"), + PathBuf::from("/usr/local/bin/cmux"), + ]); + + for p in candidates { + if p.is_file() { + return Ok(p); + } + } + + if let Some(p) = resolve_via_zsh_login_shell() { + return Ok(p); + } + + Err( + "cmux CLI not found. Install cmux, or set CMUX_CLI to the binary (e.g. /opt/homebrew/bin/cmux)." + .into(), + ) +} + +#[cfg(target_os = "macos")] +fn resolve_via_zsh_login_shell() -> Option { + let output = Command::new("/bin/zsh") + .args(["-l", "-c", "command -v cmux"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if s.is_empty() { + return None; + } + let p = PathBuf::from(s); + if p.is_file() { + Some(p) + } else { + None + } +} + +#[cfg(target_os = "macos")] +fn format_cmux_failure(output: &std::process::Output, step: &str) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let mut msg = format!("cmux {step} failed (status {:?})", output.status.code()); + if !stderr.trim().is_empty() { + msg.push_str(": "); + msg.push_str(stderr.trim()); + } else if !stdout.trim().is_empty() { + msg.push_str(": "); + msg.push_str(stdout.trim()); + } + msg.push_str( + " | Fix: in CC Switch → Settings → Preferred Terminal, use “Restart cmux for external control”, or in cmux Settings enable socket access for all local processes, or quit cmux and run: CMUX_SOCKET_MODE=allowAll open -a cmux (see https://www.cmux.dev/docs/api ).", + ); + msg +} + +#[cfg(target_os = "macos")] +fn run_cmux_checked(exe: &Path, args: &[&str], step: &str) -> Result<(), String> { + let output = Command::new(exe) + .env("CMUX_SOCKET_MODE", "allowAll") + .args(args) + .output() + .map_err(|e| format!("failed to spawn cmux ({step}): {e}"))?; + if output.status.success() { + Ok(()) + } else { + Err(format_cmux_failure(&output, step)) + } +} + +/// Open cmux, create a new workspace, and `send` the given text (include `\n` if you need Enter). +#[cfg(target_os = "macos")] +pub fn run_in_cmux(send_text: &str) -> Result<(), String> { + activate_cmux_app()?; + thread::sleep(Duration::from_millis(900)); + + let exe = resolve_cmux_cli()?; + run_cmux_checked(&exe, &["new-workspace"], "new-workspace")?; + thread::sleep(Duration::from_millis(350)); + run_cmux_checked(&exe, &["send", send_text], "send")?; + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +pub fn run_in_cmux(_send_text: &str) -> Result<(), String> { + Err("cmux is only supported on macOS".into()) +} diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 5001e1f37..a1648a294 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -948,11 +948,13 @@ exec bash --norc --noprofile "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), + "cmux" => launch_macos_cmux(&script_file, cwd), _ => 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" { + // If preferred terminal fails and it's not the default, try Terminal.app as fallback. + // cmux: do not fall back — failures are usually PATH or cmux socket policy; user should see the error. + if result.is_err() && terminal != "terminal" && terminal != "cmux" { log::warn!( "首选终端 {} 启动失败,回退到 Terminal.app: {:?}", terminal, @@ -1065,6 +1067,22 @@ fn launch_macos_open_app( Ok(()) } +/// macOS: cmux (terminal built on Ghostty with workspace management) +#[cfg(target_os = "macos")] +fn launch_macos_cmux(script_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> { + let mut cmd_text = String::new(); + if let Some(dir) = cwd { + cmd_text.push_str(&format!( + "cd {} && ", + shell_single_quote(&dir.to_string_lossy()) + )); + } + cmd_text.push_str(&format!("bash '{}'\n", script_file.display())); + + crate::cmux_macos::run_in_cmux(&cmd_text) + .map_err(|e| format!("启动 cmux 失败: {e}")) +} + /// Linux: 根据用户首选终端启动 #[cfg(target_os = "linux")] fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> { @@ -1324,6 +1342,19 @@ pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<() window.set_theme(tauri_theme).map_err(|e| e.to_string()) } +/// Quit cmux and relaunch with `CMUX_SOCKET_MODE=allowAll` so CC Switch can run `cmux new-workspace` / `send`. +#[cfg(target_os = "macos")] +#[tauri::command] +pub fn restart_cmux_for_external_access() -> Result<(), String> { + crate::cmux_macos::restart_cmux_with_allow_all() +} + +#[cfg(not(target_os = "macos"))] +#[tauri::command] +pub fn restart_cmux_for_external_access() -> Result<(), String> { + Err("cmux is only supported on macOS".into()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 277713bc7..615dd0793 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod claude_mcp; mod claude_plugin; mod codex_config; mod commands; +mod cmux_macos; mod config; mod database; mod deeplink; @@ -1034,6 +1035,7 @@ pub fn run() { commands::get_tool_versions, // Provider terminal commands::open_provider_terminal, + commands::restart_cmux_for_external_access, // Universal Provider management commands::get_universal_providers, commands::get_universal_provider, diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs index ab13466ba..ac446b2c7 100644 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -21,6 +21,7 @@ pub fn launch_terminal( "kitty" => launch_kitty(command, cwd), "wezterm" => launch_wezterm(command, cwd), "alacritty" => launch_alacritty(command, cwd), + "cmux" => launch_cmux(command, cwd), "custom" => launch_custom(command, cwd, custom_config), _ => Err(format!("Unsupported terminal target: {target}")), } @@ -211,6 +212,12 @@ fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> { } } +fn launch_cmux(command: &str, cwd: Option<&str>) -> Result<(), String> { + let full_command = build_shell_command(command, cwd); + let cmd_text = format!("{}\n", full_command); + crate::cmux_macos::run_in_cmux(&cmd_text) +} + fn launch_custom( command: &str, cwd: Option<&str>, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b44929dd1..2a65cf25c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -264,7 +264,7 @@ pub struct AppSettings { // ===== 终端设置 ===== /// 首选终端应用(可选,默认使用系统默认终端) - /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" + /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "cmux" /// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal) /// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/components/settings/TerminalSettings.tsx b/src/components/settings/TerminalSettings.tsx index c1d62a158..3f23115f8 100644 --- a/src/components/settings/TerminalSettings.tsx +++ b/src/components/settings/TerminalSettings.tsx @@ -1,4 +1,6 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import { Select, SelectContent, @@ -6,7 +8,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; import { isMac, isWindows, isLinux } from "@/lib/platform"; +import { settingsApi } from "@/lib/api/settings"; // Terminal options per platform const MACOS_TERMINALS = [ @@ -16,6 +20,7 @@ const MACOS_TERMINALS = [ { value: "kitty", labelKey: "settings.terminal.options.macos.kitty" }, { value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" }, { value: "wezterm", labelKey: "settings.terminal.options.macos.wezterm" }, + { value: "cmux", labelKey: "settings.terminal.options.macos.cmux" }, ] as const; const WINDOWS_TERMINALS = [ @@ -80,10 +85,27 @@ export function TerminalSettings({ value, onChange }: TerminalSettingsProps) { const { t } = useTranslation(); const terminals = getTerminalOptions(); const defaultTerminal = getDefaultTerminal(); + const [cmuxRestarting, setCmuxRestarting] = useState(false); // Use value or default const currentValue = value || defaultTerminal; + const handleCmuxRestart = async () => { + setCmuxRestarting(true); + try { + await settingsApi.restartCmuxForExternalAccess(); + toast.success(t("settings.terminal.cmuxRestartSuccess")); + } catch (e) { + toast.error( + t("settings.terminal.cmuxRestartFailed", { + message: e instanceof Error ? e.message : String(e), + }), + ); + } finally { + setCmuxRestarting(false); + } + }; + return (
@@ -107,6 +129,24 @@ export function TerminalSettings({ value, onChange }: TerminalSettingsProps) {

{t("settings.terminal.fallbackHint")}

+ {isMac() && currentValue === "cmux" ? ( +
+

+ {t("settings.terminal.cmuxSocketHint")} +

+ +
+ ) : null}
); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 09f421fce..1810dc2d3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -489,6 +489,11 @@ "title": "Preferred Terminal", "description": "Choose which terminal app to use when clicking the terminal button", "fallbackHint": "If the selected terminal is unavailable, the system default will be used", + "cmuxSocketHint": "cmux’s socket defaults to rejecting apps like CC Switch. If you see “Failed to write to socket”, click the button below to quit cmux and relaunch it with external control enabled (your cmux sessions will close).", + "cmuxRestartButton": "Restart cmux for external control", + "cmuxRestarting": "Restarting…", + "cmuxRestartSuccess": "cmux restarted in external-control mode. Try Open Terminal again.", + "cmuxRestartFailed": "Could not restart cmux: {{message}}", "options": { "macos": { "terminal": "Terminal.app", @@ -496,7 +501,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "cmux": "cmux" }, "windows": { "cmd": "Command Prompt", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 145355ef3..3aaefa27a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -489,6 +489,11 @@ "title": "優先ターミナル", "description": "ターミナルボタンをクリックした時に使用するターミナルアプリを選択", "fallbackHint": "選択したターミナルが利用できない場合、システムのデフォルトが使用されます", + "cmuxSocketHint": "cmux のソケットは既定で外部アプリからの接続を拒否します。「Failed to write to socket」が出たら、下のボタンで cmux を終了し、外部制御を許可するモードで再起動してください(セッションは閉じます)。", + "cmuxRestartButton": "cmux を再起動(外部制御を許可)", + "cmuxRestarting": "再起動中…", + "cmuxRestartSuccess": "cmux を再起動しました。もう一度ターミナルを開いてください。", + "cmuxRestartFailed": "再起動に失敗しました: {{message}}", "options": { "macos": { "terminal": "Terminal.app", @@ -496,7 +501,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "cmux": "cmux" }, "windows": { "cmd": "コマンドプロンプト", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 75fc9232a..7c2936e0d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -489,6 +489,11 @@ "title": "首选终端", "description": "选择点击终端按钮时使用的终端应用", "fallbackHint": "如果选择的终端不可用,将自动使用系统默认终端", + "cmuxSocketHint": "cmux 默认不允许外部应用通过套接字控制。若出现 “Failed to write to socket”,请点击下方按钮:将退出 cmux 并以允许外部进程的方式重新启动(当前 cmux 会话会关闭)。", + "cmuxRestartButton": "重启 cmux(允许外部控制)", + "cmuxRestarting": "正在重启…", + "cmuxRestartSuccess": "cmux 已重新启动,请再试一次「打开终端」。", + "cmuxRestartFailed": "重启失败:{{message}}", "options": { "macos": { "terminal": "Terminal.app", @@ -496,7 +501,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "cmux": "cmux" }, "windows": { "cmd": "命令提示符", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index b5b65f595..8cca01407 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -31,6 +31,11 @@ export const settingsApi = { return await invoke("restart_app"); }, + /** macOS: quit cmux and relaunch with CMUX_SOCKET_MODE=allowAll (fixes “Failed to write to socket”). */ + async restartCmuxForExternalAccess(): Promise { + await invoke("restart_cmux_for_external_access"); + }, + async checkUpdates(): Promise { await invoke("check_for_updates"); }, diff --git a/src/types.ts b/src/types.ts index 868a05cc6..2d5d6ab6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -304,7 +304,7 @@ export interface Settings { // ===== 终端设置 ===== // 首选终端应用(可选,默认使用系统默认终端) - // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" + // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "cmux" // Windows: "cmd" | "powershell" | "wt" // Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" preferredTerminal?: string;