diff --git a/apps/desktop/src-tauri/capabilities/linux.json b/apps/desktop/src-tauri/capabilities/linux.json index 63fb37f..a5fcc96 100644 --- a/apps/desktop/src-tauri/capabilities/linux.json +++ b/apps/desktop/src-tauri/capabilities/linux.json @@ -9,6 +9,7 @@ "allow-active-paste-target", "allow-preview-clipboard-image-paste", "allow-preview-active-clipboard-image-paste", + "allow-pty-spawn", "allow-pty-write", "allow-pty-interrupt", "allow-pty-resize", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6bd4ad6..2dfa5a9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -137,9 +137,16 @@ async fn pty_spawn( rows: u16, program: Option, args: Option>, -) -> Result { - let size = TerminalSize::new(cols, rows).map_err(|error| format!("{error:?}"))?; - let command = resolve_pty_command(program, args); +) -> Result { + let platform = platform::PlatformServices::detect()?; + let size = TerminalSize::new(cols, rows).map_err(|error| { + platform::PlatformError::native_mechanism( + platform.target(), + format!("invalid terminal size: {error:?}"), + false, + ) + })?; + let command = resolve_pty_command(program, args, &platform)?; let command_args = command.args.iter().map(String::as_str).collect::>(); // No predecessor `.take()`/close here: sessions are keyed by id and coexist. @@ -217,6 +224,28 @@ async fn pty_spawn( let cleanup_app = app.clone(); let exit_app = app; let closing_credit = Arc::clone(&credit); + #[cfg(unix)] + let session = PtySession::spawn_with_options_and_close_hook( + &command.program, + &command_args, + splice_pty::PtySpawnOptions { + cwd: None, + env: command.environment, + }, + size, + move |id, output| { + let _ = tx.send((id, output)); + }, + move |id| { + let _ = exit_app.emit(PTY_EXIT_EVENT, id); + let cleanup_app = exit_app.clone(); + std::thread::spawn(move || { + clear_and_close_session_by_id(&cleanup_app, id); + }); + }, + move || closing_credit.close(), + ); + #[cfg(windows)] let session = PtySession::spawn_with_close_hook( &command.program, &command_args, @@ -251,8 +280,14 @@ async fn pty_spawn( // reader parked in the blocking `send` above is always released, so a // never-acking (crashed/closed) webview can never wedge teardown. move || closing_credit.close(), - ) - .map_err(|error| error.to_string())?; + ); + let session = session.map_err(|error| { + platform::PlatformError::native_mechanism( + platform.target(), + format!("failed to start PTY: {error}"), + true, + ) + })?; let id = session.id(); // Publish the id so the flusher's stall reporter can attribute a stall event @@ -260,17 +295,23 @@ async fn pty_spawn( stall_session_id.store(id, std::sync::atomic::Ordering::SeqCst); { - let mut guard = state - .sessions - .lock() - .map_err(|_| "PTY state lock poisoned".to_owned())?; + let mut guard = state.sessions.lock().map_err(|_| { + platform::PlatformError::native_mechanism( + platform.target(), + "PTY state lock poisoned", + true, + ) + })?; guard.insert(id, Arc::new(session)); } { - let mut guard = state - .credits - .lock() - .map_err(|_| "PTY credit lock poisoned".to_owned())?; + let mut guard = state.credits.lock().map_err(|_| { + platform::PlatformError::native_mechanism( + platform.target(), + "PTY credit lock poisoned", + true, + ) + })?; guard.insert(id, credit); } @@ -285,7 +326,10 @@ async fn pty_spawn( // id-scoped, idempotent `clear_and_close_session_by_id`, so a different // session is never torn down, and its `close()` runs with the state lock // released (no thread-join deadlock). - let still_running = clone_pty_session_by_id(state.inner(), id)? + let still_running = clone_pty_session_by_id(state.inner(), id) + .map_err(|message| { + platform::PlatformError::native_mechanism(platform.target(), message, true) + })? .and_then(|session| session.is_running().ok()) .unwrap_or(false); if !still_running { @@ -372,49 +416,31 @@ fn clear_and_close_session_by_id(app: &tauri::AppHandle, id: u64) { struct PtyCommand { program: String, args: Vec, + environment: Vec<(String, String)>, } -fn resolve_pty_command(program: Option, args: Option>) -> PtyCommand { +fn resolve_pty_command( + program: Option, + args: Option>, + platform: &platform::PlatformServices, +) -> Result { match program { - Some(program) if !program.trim().is_empty() => PtyCommand { + Some(program) if !program.trim().is_empty() => Ok(PtyCommand { program, args: args.unwrap_or_default(), - }, - _ => PtyCommand { - program: "cmd.exe".to_owned(), - args: default_shell_args(), - }, + environment: vec![], + }), + _ => { + let launch = platform.pty_launch(); + Ok(PtyCommand { + program: launch.command.program, + args: launch.command.args, + environment: launch.environment, + }) + } } } -fn default_shell_args() -> Vec { - vec![ - "/D".to_owned(), - "/K".to_owned(), - format!("set PATH={};%PATH%", common_cli_path_prefix()), - ] -} - -fn common_cli_path_prefix() -> String { - let user_profile = std::env::var("USERPROFILE").unwrap_or_default(); - let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default(); - - [ - format!("{user_profile}\\.local\\bin"), - format!("{user_profile}\\scoop\\shims"), - format!("{user_profile}\\scoop\\apps\\nodejs\\current\\bin"), - format!("{user_profile}\\scoop\\apps\\nodejs\\current"), - format!("{local_app_data}\\agy\\bin"), - format!("{local_app_data}\\Programs\\OpenCode\\bin"), - format!("{local_app_data}\\Programs\\opencode\\bin"), - format!("{local_app_data}\\OpenAI\\Codex\\bin"), - ] - .into_iter() - .filter(|path| !path.starts_with('\\') && !path.is_empty()) - .collect::>() - .join(";") -} - /// Id-scoped write core, split out so its miss path is unit-testable without a /// Tauri `State`. A miss returns the EXACT string `"PTY session is not /// running"`, which the frontend's `isClosedPtyInputError` matches verbatim — @@ -1072,34 +1098,47 @@ mod tests { #[test] fn resolve_pty_command_uses_safe_default_shell() { + let linux = platform::PlatformServices::from_facts(platform::PlatformFacts { + os: "linux".into(), + ubuntu: Some("24.04".into()), + wsl: None, + wslg: false, + path: Some("/usr/local/bin:/usr/bin:/bin".into()), + }) + .expect("supported Linux platform"); + assert_eq!( - resolve_pty_command(None, None), + resolve_pty_command(None, None, &linux).expect("Linux default command"), PtyCommand { - program: "cmd.exe".to_owned(), - args: default_shell_args(), + program: "/bin/sh".to_owned(), + args: vec![], + environment: vec![("PATH".to_owned(), "/usr/local/bin:/usr/bin:/bin".to_owned())], } ); } - #[test] - fn default_shell_path_includes_common_cli_locations() { - let path_prefix = common_cli_path_prefix(); - - assert!(path_prefix.contains(".local\\bin")); - assert!(path_prefix.contains("scoop\\shims")); - assert!(path_prefix.contains("agy\\bin")); - } - #[test] fn resolve_pty_command_accepts_configured_program() { + let linux = platform::PlatformServices::from_facts(platform::PlatformFacts { + os: "linux".into(), + ubuntu: Some("24.04".into()), + wsl: None, + wslg: false, + path: Some("/usr/bin:/bin".into()), + }) + .expect("supported Linux platform"); + assert_eq!( resolve_pty_command( Some("codex.exe".to_owned()), - Some(vec!["--help".to_owned()]) - ), + Some(vec!["--help".to_owned()]), + &linux, + ) + .expect("configured command"), PtyCommand { program: "codex.exe".to_owned(), args: vec!["--help".to_owned()], + environment: vec![], } ); } diff --git a/apps/desktop/src-tauri/src/platform/mod.rs b/apps/desktop/src-tauri/src/platform/mod.rs index f26d24c..4b4a7f6 100644 --- a/apps/desktop/src-tauri/src/platform/mod.rs +++ b/apps/desktop/src-tauri/src/platform/mod.rs @@ -55,6 +55,18 @@ impl PlatformError { retryable, } } + pub fn native_mechanism( + target: PlatformTarget, + message: impl Into, + retryable: bool, + ) -> Self { + Self::target( + target, + PlatformErrorCode::NativeMechanismFailed, + message, + retryable, + ) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -73,6 +85,12 @@ impl ShellCommand { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PtyLaunch { + pub command: ShellCommand, + pub environment: Vec<(String, String)>, +} + pub struct PlatformFacts { pub os: String, pub ubuntu: Option, @@ -148,6 +166,21 @@ impl PlatformServices { pub fn path(&self) -> &str { &self.path } + pub fn pty_launch(&self) -> PtyLaunch { + PtyLaunch { + command: match self.target { + PlatformTarget::Windows => windows::shell(), + PlatformTarget::NativeUbuntu => linux::shell(), + PlatformTarget::Wsl2Wslg => wsl::shell(), + }, + environment: match self.target { + PlatformTarget::Windows => vec![], + PlatformTarget::NativeUbuntu | PlatformTarget::Wsl2Wslg => { + vec![("PATH".into(), self.path.clone())] + } + }, + } + } pub fn reveal_command(&self, path: impl AsRef) -> Result { let path = path.as_ref(); if !path.is_absolute() { diff --git a/apps/desktop/src-tauri/src/platform/windows.rs b/apps/desktop/src-tauri/src/platform/windows.rs index d06b35f..9868630 100644 --- a/apps/desktop/src-tauri/src/platform/windows.rs +++ b/apps/desktop/src-tauri/src/platform/windows.rs @@ -1,8 +1,35 @@ use super::ShellCommand; use std::path::Path; pub(super) fn shell() -> ShellCommand { - ShellCommand::new("cmd.exe", ["/D", "/K"]) + ShellCommand::new( + "cmd.exe", + [ + "/D".to_owned(), + "/K".to_owned(), + format!("set PATH={};%PATH%", common_cli_path_prefix()), + ], + ) } pub(super) fn reveal(path: &Path) -> ShellCommand { ShellCommand::new("explorer.exe", [format!("/select,{}", path.display())]) } + +fn common_cli_path_prefix() -> String { + let user_profile = std::env::var("USERPROFILE").unwrap_or_default(); + let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default(); + + [ + format!("{user_profile}\\.local\\bin"), + format!("{user_profile}\\scoop\\shims"), + format!("{user_profile}\\scoop\\apps\\nodejs\\current\\bin"), + format!("{user_profile}\\scoop\\apps\\nodejs\\current"), + format!("{local_app_data}\\agy\\bin"), + format!("{local_app_data}\\Programs\\OpenCode\\bin"), + format!("{local_app_data}\\Programs\\opencode\\bin"), + format!("{local_app_data}\\OpenAI\\Codex\\bin"), + ] + .into_iter() + .filter(|path| !path.starts_with('\\') && !path.is_empty()) + .collect::>() + .join(";") +} diff --git a/apps/desktop/src-tauri/tests/authority_manifest.rs b/apps/desktop/src-tauri/tests/authority_manifest.rs index c984042..e079add 100644 --- a/apps/desktop/src-tauri/tests/authority_manifest.rs +++ b/apps/desktop/src-tauri/tests/authority_manifest.rs @@ -121,22 +121,15 @@ fn target_capabilities_grant_only_the_finite_terminal_authority() { .expect("target capability must be valid JSON"); assert_eq!(capability["windows"], Value::from(["main"])); assert_eq!(capability["platforms"], Value::from([platform])); - let expected_permissions: Vec<_> = TERMINAL_PERMISSIONS - .iter() - .copied() - .filter(|permission| platform == "windows" || *permission != "allow-pty-spawn") - .collect(); + let expected_permissions: Vec<_> = TERMINAL_PERMISSIONS.to_vec(); assert_eq!( capability["permissions"], serde_json::to_value(expected_permissions).expect("permission list must serialize") ); - assert_eq!( - capability["permissions"] - .as_array() - .expect("target permissions must be an array") - .contains(&Value::from("allow-pty-spawn")), - platform == "windows" - ); + assert!(capability["permissions"] + .as_array() + .expect("target permissions must be an array") + .contains(&Value::from("allow-pty-spawn"))); } let default_capability: Value = diff --git a/apps/desktop/src-tauri/tests/platform_authority.rs b/apps/desktop/src-tauri/tests/platform_authority.rs index 801de31..f03e093 100644 --- a/apps/desktop/src-tauri/tests/platform_authority.rs +++ b/apps/desktop/src-tauri/tests/platform_authority.rs @@ -34,7 +34,11 @@ fn platform_services_preserve_windows_and_match_linux_wsl_commands() { windows.shell().expect("Windows shell"), ShellCommand { program: "cmd.exe".into(), - args: vec!["/D".into(), "/K".into()], + args: vec![ + "/D".into(), + "/K".into(), + windows.pty_launch().command.args[2].clone(), + ], } ); assert_eq!( @@ -76,6 +80,27 @@ fn platform_services_preserve_windows_and_match_linux_wsl_commands() { wsl.reveal_command(&known_path), ubuntu.reveal_command(&known_path) ); + + let windows_launch = windows.pty_launch(); + assert_eq!(windows_launch.command.program, "cmd.exe"); + assert_eq!(&windows_launch.command.args[..2], ["/D", "/K"]); + assert!(windows_launch.command.args[2].starts_with("set PATH=")); + assert!(windows_launch.command.args[2].ends_with(";%PATH%")); + assert!(windows_launch.environment.is_empty()); + + let ubuntu_launch = ubuntu.pty_launch(); + assert_eq!(ubuntu_launch.command, ubuntu.shell().expect("Ubuntu shell")); + assert_eq!( + ubuntu_launch.environment, + vec![("PATH".into(), "/usr/bin:/bin".into())] + ); + + let wsl_launch = wsl.pty_launch(); + assert_eq!(wsl_launch.command, wsl.shell().expect("WSL shell")); + assert_eq!( + wsl_launch.environment, + vec![("PATH".into(), "/usr/bin:/bin".into())] + ); } #[test] @@ -107,4 +132,21 @@ fn platform_services_return_structured_errors_without_fallback() { let relative_path = windows.reveal_command("relative/path").unwrap_err(); assert_eq!(relative_path.code, PlatformErrorCode::InvalidPath); assert_eq!(relative_path.platform, Some(PlatformTarget::Windows)); + + let missing_path = windows + .reveal_command(std::env::temp_dir().join("missing-splice-path")) + .unwrap_err(); + assert_eq!(missing_path.code, PlatformErrorCode::MissingPath); + assert_eq!(missing_path.platform, Some(PlatformTarget::Windows)); + + let missing_environment = + PlatformServices::from_facts(facts("linux", Some("24.04"), None, false, None)).unwrap_err(); + assert_eq!( + missing_environment.code, + PlatformErrorCode::MissingEnvironment + ); + assert_eq!( + missing_environment.platform, + Some(PlatformTarget::NativeUbuntu) + ); } diff --git a/apps/desktop/src/terminal/ptyClient.test.ts b/apps/desktop/src/terminal/ptyClient.test.ts index cb70282..4fa5d5b 100644 --- a/apps/desktop/src/terminal/ptyClient.test.ts +++ b/apps/desktop/src/terminal/ptyClient.test.ts @@ -8,6 +8,7 @@ import { PTY_OUTPUT_EVENT, PTY_STALL_EVENT, resizePty, + spawnPty, writePty, } from "./ptyClient"; @@ -87,4 +88,32 @@ describe("ptyClient", () => { void killPty(5); expect(invokeMock).toHaveBeenCalledWith("pty_kill", { sessionId: 5 }); }); + + it("leaves the default shell selection to the target-aware backend", () => { + invokeMock.mockResolvedValue(7); + void spawnPty({ cols: 80, rows: 24 }); + + expect(invokeMock).toHaveBeenCalledWith("pty_spawn", { + cols: 80, + rows: 24, + program: undefined, + args: undefined, + }); + }); + + it("preserves an explicit command and argv in the PTY IPC payload", () => { + invokeMock.mockResolvedValue(8); + void spawnPty({ + cols: 100, + rows: 40, + command: { program: "/usr/bin/fish", args: ["--login"] }, + }); + + expect(invokeMock).toHaveBeenCalledWith("pty_spawn", { + cols: 100, + rows: 40, + program: "/usr/bin/fish", + args: ["--login"], + }); + }); }); diff --git a/apps/desktop/src/ui/TerminalView.test.tsx b/apps/desktop/src/ui/TerminalView.test.tsx index 07f6a14..c244fe8 100644 --- a/apps/desktop/src/ui/TerminalView.test.tsx +++ b/apps/desktop/src/ui/TerminalView.test.tsx @@ -4,6 +4,7 @@ import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PTY_EXIT_EVENT, PTY_OUTPUT_EVENT, PTY_STALL_EVENT } from "../terminal/ptyClient"; import { TerminalView } from "./TerminalView"; +import { createTerminalOptions } from "./usePtySession"; // jsdom does not implement requestAnimationFrame. TerminalView's output scheduler // (terminalOutputScheduler.ts) relies on it to flush buffered PTY output into xterm, so the @@ -1368,6 +1369,21 @@ describe("TerminalView settings prop", () => { }); }); +describe("TerminalView platform options", () => { + const settings = { background: "#000000", foreground: "#ffffff", fontSize: 14 }; + + it("uses ConPTY only for a Windows renderer", () => { + expect(createTerminalOptions(settings, "Windows NT 10.0").windowsPty).toEqual({ + backend: "conpty", + }); + }); + + it("does not pass Windows-only xterm options to Linux or WSL renderers", () => { + expect(createTerminalOptions(settings, "X11; Linux x86_64").windowsPty).toBeUndefined(); + expect(createTerminalOptions(settings, "Linux; WSL2").windowsPty).toBeUndefined(); + }); +}); + describe("TerminalView multi-instance event isolation", () => { it("drops foreign-session pty-output while not spawning (never enqueued)", async () => { render(); diff --git a/apps/desktop/src/ui/usePtySession.ts b/apps/desktop/src/ui/usePtySession.ts index e5bcdb5..9bf9c10 100644 --- a/apps/desktop/src/ui/usePtySession.ts +++ b/apps/desktop/src/ui/usePtySession.ts @@ -48,6 +48,36 @@ export type UsePtySessionOptions = { onPtyReady?: (sessionId: number) => void; }; +export function createTerminalOptions( + settings: TerminalSettings, + userAgent = navigator.userAgent, +): NonNullable[0]> { + const options = { + allowProposedApi: true, + cursorBlink: true, + fontFamily: + '"CaskaydiaCove Nerd Font", "CaskaydiaCove NF", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Cascadia Code", "Fira Code", Consolas, monospace', + fontSize: settings.fontSize, + theme: { + background: settings.background, + foreground: settings.foreground, + cursor: "#38bdf8", + selectionBackground: "#1e3a8a", + }, + windowOptions: { + getCellSizePixels: true, + getWinSizePixels: true, + getWinSizeChars: true, + }, + }; + + if (userAgent.includes("Windows")) { + return { ...options, windowsPty: { backend: "conpty" } }; + } + + return options; +} + // Owns the entire PTY session lifecycle: xterm construction, the spawn/restart // state machine, restart-storm guard, early-output queue, unmatched-exit // tracking, generation/session-id demultiplexing, health reporting, stall @@ -133,27 +163,7 @@ export function usePtySession({ return undefined; } - const terminal = new Terminal({ - allowProposedApi: true, - cursorBlink: true, - fontFamily: - '"CaskaydiaCove Nerd Font", "CaskaydiaCove NF", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Cascadia Code", "Fira Code", Consolas, monospace', - fontSize: effectiveSettings.fontSize, - theme: { - background: effectiveSettings.background, - foreground: effectiveSettings.foreground, - cursor: "#38bdf8", - selectionBackground: "#1e3a8a", - }, - windowOptions: { - getCellSizePixels: true, - getWinSizePixels: true, - getWinSizeChars: true, - }, - windowsPty: { - backend: "conpty", - }, - }); + const terminal = new Terminal(createTerminalOptions(effectiveSettings)); terminalRef.current = terminal; const fitAddon = new FitAddon(); fitAddonRef.current = fitAddon; diff --git a/crates/splice-pty/src/lib.rs b/crates/splice-pty/src/lib.rs index 0714f72..245df15 100644 --- a/crates/splice-pty/src/lib.rs +++ b/crates/splice-pty/src/lib.rs @@ -285,7 +285,7 @@ impl PtySession { ) } - fn spawn_with_options_and_close_hook( + pub fn spawn_with_options_and_close_hook( program: &str, args: &[&str], options: PtySpawnOptions,