diff --git a/packages/electron-app/electron/main/user-shell.test.ts b/packages/electron-app/electron/main/user-shell.test.ts new file mode 100644 index 000000000..b6563fd83 --- /dev/null +++ b/packages/electron-app/electron/main/user-shell.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { getDefaultShellPath } from "./user-shell" + +describe("desktop POSIX shell selection", () => { + it("falls back from incompatible user shells", () => { + assert.equal(getDefaultShellPath("darwin", "/opt/homebrew/bin/nu"), "/bin/zsh") + assert.equal(getDefaultShellPath("linux", "/usr/bin/fish"), "/bin/bash") + assert.equal(getDefaultShellPath("darwin", "/tmp/not-bash"), "/bin/zsh") + assert.equal(getDefaultShellPath("darwin", " "), "/bin/zsh") + }) + + it("preserves configured bash and zsh paths", () => { + assert.equal(getDefaultShellPath("darwin", "/opt/homebrew/bin/bash"), "/opt/homebrew/bin/bash") + assert.equal(getDefaultShellPath("linux", " /usr/local/bin/zsh "), "/usr/local/bin/zsh") + }) +}) diff --git a/packages/electron-app/electron/main/user-shell.ts b/packages/electron-app/electron/main/user-shell.ts index ee49e7c45..5d56c853b 100644 --- a/packages/electron-app/electron/main/user-shell.ts +++ b/packages/electron-app/electron/main/user-shell.ts @@ -8,18 +8,29 @@ interface ShellCommand { const isWindows = process.platform === "win32" -function getDefaultShellPath(): string { - if (process.env.SHELL && process.env.SHELL.trim().length > 0) { - return process.env.SHELL +export function getDefaultShellPath( + platform: NodeJS.Platform = process.platform, + configuredShell = process.env.SHELL, +): string { + const shellPath = configuredShell?.trim() + if (shellPath && isSupportedPosixShell(shellPath)) { + return shellPath } - if (process.platform === "darwin") { + // The launch script uses POSIX syntax. Never pass it to arbitrary user shells + // such as Nushell or Fish even when they are configured through $SHELL. + if (platform === "darwin") { return "/bin/zsh" } return "/bin/bash" } +function isSupportedPosixShell(shellPath: string): boolean { + const shellName = path.basename(shellPath).toLowerCase() + return shellName === "bash" || shellName === "zsh" +} + function wrapCommandForShell(command: string, shellPath: string): string { const shellName = path.basename(shellPath) diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 7730e4fec..e0da85c41 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -24,7 +24,7 @@ "prebuild": "npm run prepare:resources", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", - "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/developer-mode.test.ts electron/main/local-window-registry.test.ts electron/main/menu-target.test.ts electron/main/multiwindow-lifecycle.test.ts electron/main/native-request.test.ts electron/main/navigation-security.test.ts electron/main/preferences-ipc.test.ts electron/main/preferences-window.test.ts electron/main/process-exit.test.ts electron/main/process-output.test.ts electron/main/process-stop.test.ts electron/main/remote-window-registry.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/startup.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts electron/preload/index.test.ts", + "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/developer-mode.test.ts electron/main/local-window-registry.test.ts electron/main/menu-target.test.ts electron/main/multiwindow-lifecycle.test.ts electron/main/native-request.test.ts electron/main/navigation-security.test.ts electron/main/preferences-ipc.test.ts electron/main/preferences-window.test.ts electron/main/process-exit.test.ts electron/main/process-output.test.ts electron/main/process-stop.test.ts electron/main/remote-window-registry.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/startup.test.ts electron/main/user-shell.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts electron/preload/index.test.ts", "preview": "electron-vite preview", "build:binaries": "node scripts/build.js", "build:mac": "node scripts/build.js mac", diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index a575b1bdc..2b120b459 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -1128,7 +1128,7 @@ impl CliProcessManager { } let command_info = if use_user_shell { - log_line("spawning via user shell"); + log_line("spawning via POSIX-compatible shell"); ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?) } else { log_line(if resolution.runner == Runner::Tsx { @@ -1872,17 +1872,32 @@ fn build_shell_command_string( ); let wrapped_command = wrap_command_for_shell(&command, &shell); let args = build_shell_args(&shell, &wrapped_command); - log_line(&format!("user shell command: {} {:?}", shell, args)); + log_line(&format!("POSIX shell command: {} {:?}", shell, args)); Ok(ShellCommand { shell, args }) } fn default_shell() -> String { - if let Ok(shell) = std::env::var("SHELL") { - if !shell.trim().is_empty() { - return shell; - } - } - if cfg!(target_os = "macos") { + select_posix_shell( + std::env::var("SHELL").ok().as_deref(), + cfg!(target_os = "macos"), + ) +} + +fn select_posix_shell(configured_shell: Option<&str>, macos: bool) -> String { + if let Some(shell) = configured_shell.map(str::trim).filter(|shell| { + let name = std::path::Path::new(shell) + .file_name() + .and_then(OsStr::to_str) + .unwrap_or("") + .to_lowercase(); + matches!(name.as_str(), "bash" | "zsh") + }) { + return shell.to_string(); + } + + // The launch script uses POSIX syntax. Never pass it to arbitrary user shells + // such as Nushell or Fish even when they are configured through $SHELL. + if macos { "/bin/zsh".to_string() } else { "/bin/bash".to_string() @@ -2025,6 +2040,32 @@ mod tests { ); } + #[test] + fn posix_shell_selection_rejects_incompatible_user_shells() { + assert_eq!( + select_posix_shell(Some("/opt/homebrew/bin/nu"), true), + "/bin/zsh" + ); + assert_eq!( + select_posix_shell(Some("/usr/bin/fish"), false), + "/bin/bash" + ); + assert_eq!(select_posix_shell(Some("/tmp/not-bash"), true), "/bin/zsh"); + assert_eq!(select_posix_shell(Some(" "), true), "/bin/zsh"); + } + + #[test] + fn posix_shell_selection_preserves_bash_and_zsh() { + assert_eq!( + select_posix_shell(Some("/opt/homebrew/bin/bash"), true), + "/opt/homebrew/bin/bash" + ); + assert_eq!( + select_posix_shell(Some(" /usr/local/bin/zsh "), false), + "/usr/local/bin/zsh" + ); + } + #[test] fn augment_launch_url_trims_leading_fragment_marker() { let _guard = ENV_LOCK.lock().expect("env lock poisoned");