From d7e7fba6700bd0c2f9f5269a3e423f6b6c6444d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 4 Sep 2026 14:11:30 +0200 Subject: [PATCH] fix(desktop): avoid incompatible startup shells Restrict POSIX desktop launch scripts to Bash and Zsh instead of passing them to any executable named by SHELL. Fall back to the system Zsh on macOS and Bash on Linux so Nushell and Fish users no longer see a blank window while the backend startup script fails to parse. Keep compatible configured shell paths and their existing login environment behavior intact. Apply the same selection policy to Tauri and Electron so both desktop hosts launch consistently. Add focused Rust and Node tests for incompatible-shell fallback, compatible-shell preservation, and platform defaults. Validate the complete Tauri and Electron native suites, desktop typechecks, formatting, and the production Electron build. --- .../electron/main/user-shell.test.ts | 18 ++++++ .../electron-app/electron/main/user-shell.ts | 19 +++++-- packages/electron-app/package.json | 2 +- .../tauri-app/src-tauri/src/cli_manager.rs | 57 ++++++++++++++++--- 4 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 packages/electron-app/electron/main/user-shell.test.ts 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");