Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions src-tauri/src/cmux_macos.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
let mut candidates: Vec<PathBuf> = 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<PathBuf, String> {
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<PathBuf> = 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<PathBuf> {
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())
}
35 changes: 33 additions & 2 deletions src-tauri/src/commands/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clean up temp credential files on cmux launch errors

The new terminal != "cmux" guard means cmux launch failures now return immediately without any cleanup path, even though launch_terminal_with_env has already written claude_<provider>_<pid>.json (with provider env/API keys) and cc_switch_launcher_<pid>.sh to temp storage. Those files are only removed by the shell trap inside the launcher script, which never executes when launch_macos_cmux fails (for example during the documented socket-permission failure path), so repeated failures can leave sensitive credentials on disk.

Useful? React with 👍 / 👎.

log::warn!(
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
terminal,
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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::*;
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod claude_mcp;
mod claude_plugin;
mod codex_config;
mod commands;
mod cmux_macos;
mod config;
mod database;
mod deeplink;
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/session_manager/terminal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")),
}
Expand Down Expand Up @@ -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>,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
40 changes: 40 additions & 0 deletions src/components/settings/TerminalSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
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 = [
Expand All @@ -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 = [
Expand Down Expand Up @@ -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 (
<section className="space-y-2">
<header className="space-y-1">
Expand All @@ -107,6 +129,24 @@ export function TerminalSettings({ value, onChange }: TerminalSettingsProps) {
<p className="text-xs text-muted-foreground">
{t("settings.terminal.fallbackHint")}
</p>
{isMac() && currentValue === "cmux" ? (
<div className="space-y-2 rounded-md border border-border/60 bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">
{t("settings.terminal.cmuxSocketHint")}
</p>
<Button
type="button"
variant="secondary"
size="sm"
disabled={cmuxRestarting}
onClick={() => void handleCmuxRestart()}
>
{cmuxRestarting
? t("settings.terminal.cmuxRestarting")
: t("settings.terminal.cmuxRestartButton")}
</Button>
</div>
) : null}
</section>
);
}
Loading
Loading