diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 831f06f02d3..28955debf7b 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -152,6 +152,108 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("hydrates the locale from the login shell on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + LANG: "de_DE.UTF-8", + }), + }); + + assert.equal(env.LANG, "de_DE.UTF-8"); + }), + ); + + it.effect("preserves an inherited locale over the login shell on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + LANG: "en_US.UTF-8", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + LANG: "de_DE.UTF-8", + }), + }); + + assert.equal(env.LANG, "en_US.UTF-8"); + }), + ); + + it.effect("does not mix login-shell locale categories into an inherited locale", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + LANG: "en_US.UTF-8", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + LC_ALL: "de_DE.UTF-8", + }), + }); + + assert.equal(env.LANG, "en_US.UTF-8"); + assert.equal(env.LC_ALL, undefined); + }), + ); + + it.effect("falls back to a UTF-8 LC_CTYPE when no locale is available on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }), + }); + + assert.equal(env.LANG, undefined); + assert.equal(env.LC_ALL, undefined); + assert.equal(env.LC_CTYPE, "en_US.UTF-8"); + }), + ); + + it.effect("does not apply the locale fallback on linux", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => envOutput({ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin" }), + }); + + assert.equal(env.LANG, undefined); + }), + ); + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index bd8aa6654f7..e065bf55d04 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -71,6 +71,9 @@ const LOGIN_SHELL_ENV_NAMES = [ "PATH", "DBUS_SESSION_BUS_ADDRESS", "DISPLAY", + "LANG", + "LC_ALL", + "LC_CTYPE", "SSH_AUTH_SOCK", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", @@ -84,6 +87,8 @@ const LOGIN_SHELL_ENV_NAMES = [ "WAYLAND_DISPLAY", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; +const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; +const FALLBACK_LC_CTYPE = "en_US.UTF-8"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); const LAUNCHCTL_TIMEOUT = Duration.seconds(2); @@ -472,6 +477,29 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } + // Locale variables form one precedence group: LC_ALL can override an inherited + // LANG or LC_CTYPE, so only hydrate the group when the process has none of them. + if ( + config.platform === "darwin" && + LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name]))) + ) { + for (const name of LOCALE_ENV_NAMES) { + const value = trimNonEmpty(shellEnvironment[name]); + if (Option.isSome(value)) { + config.env[name] = value.value; + } + } + + // GUI launches inherit no locale from launchd, so spawned agents land in the C + // locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported + // macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale + // available on those releases. Leaving LANG unset keeps C-stable collation and + // formatting, so output parsing is unaffected. + if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) { + config.env.LC_CTYPE = FALLBACK_LC_CTYPE; + } + } + if ( config.platform === "linux" && Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS))