From 3039d339700652731207a4d9734e8611fb59a8e5 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Sat, 4 Apr 2026 18:03:00 +0200 Subject: [PATCH] fix(locale): strip POSIX encoding suffix from macOS locale vars to prevent ICU crash On macOS 26+, ICU's uloc_getTableStringWithFallback crashes with a null pointer dereference when locale strings contain POSIX encoding suffixes (e.g. "en_US.UTF-8"). ICU expects bare locale identifiers like "en_US" and does not understand the ".UTF-8" encoding marker. The previous fix (PR #1640) changed the fallback from C.UTF-8 to en_US.UTF-8, but any .UTF-8 suffix triggers the same crash path in the newer macOS 26 ICU. macOS always uses UTF-8 regardless of locale, so the suffix carries no information and only breaks ICU lookup. Changes: - Strip POSIX encoding suffixes from all locale env vars after locale initialization on macOS (applied as a final pass in initializeShellEnvironment so it catches values from any source: existing env, shell detection, or fallback) - Change getFallbackUtf8Locale() on darwin to return "en_US" (no suffix) - Change LSEnvironment in package.json to use "en_US" / "en_US" so the Info.plist value is also ICU-safe before any JS runs - Update tests to reflect suffix-stripped expectations on macOS Closes #1633 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 4 +-- src/main/utils/__tests__/shellEnv.test.ts | 35 +++++++++++++---------- src/main/utils/shellEnv.ts | 33 ++++++++++++++++++--- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index f22cf9ba7..c2f313dcc 100644 --- a/package.json +++ b/package.json @@ -180,8 +180,8 @@ "entitlementsInherit": "build/entitlements.mac.plist", "extendInfo": { "LSEnvironment": { - "LANG": "en_US.UTF-8", - "LC_CTYPE": "en_US.UTF-8" + "LANG": "en_US", + "LC_CTYPE": "en_US" } }, "target": [ diff --git a/src/main/utils/__tests__/shellEnv.test.ts b/src/main/utils/__tests__/shellEnv.test.ts index 7f77866d1..88fa10aef 100644 --- a/src/main/utils/__tests__/shellEnv.test.ts +++ b/src/main/utils/__tests__/shellEnv.test.ts @@ -22,11 +22,7 @@ const mockedReaddirSync = vi.mocked(readdirSync); describe('shellEnv', () => { const originalEnv = process.env; const fallbackUtf8Locale = - process.platform === 'darwin' - ? 'en_US.UTF-8' - : process.platform === 'win32' - ? undefined - : 'C.UTF-8'; + process.platform === 'darwin' ? 'en_US' : process.platform === 'win32' ? undefined : 'C.UTF-8'; const shellLookup = (values: Partial>) => (command: string) => { // Batched locale call: returns values separated by --- if (command.includes('echo "---"')) { @@ -174,9 +170,12 @@ describe('shellEnv', () => { initializeShellEnvironment(); expect(process.env.SSH_AUTH_SOCK).toBe('/detected/socket'); - expect(process.env.LANG).toBe('C.UTF-8'); - expect(process.env.LC_CTYPE).toBe('C.UTF-8'); - expect(process.env.LC_ALL).toBe('C.UTF-8'); + // On macOS, POSIX encoding suffixes are stripped from locale strings to + // prevent ICU crashes during AppKit menu init on macOS 26+. + const expectedLocale = process.platform === 'darwin' ? 'C' : 'C.UTF-8'; + expect(process.env.LANG).toBe(expectedLocale); + expect(process.env.LC_CTYPE).toBe(expectedLocale); + expect(process.env.LC_ALL).toBe(expectedLocale); }); it('should fall back to existing SSH_AUTH_SOCK when launchctl fails', () => { @@ -206,9 +205,12 @@ describe('shellEnv', () => { initializeShellEnvironment(); - expect(process.env.LANG).toBe('en_US.UTF-8'); - expect(process.env.LC_CTYPE).toBe('sr_RS.UTF-8'); - expect(process.env.LC_ALL).toBe('C.UTF-8'); + // On macOS, encoding suffixes are stripped to prevent ICU crashes. + const strip = (v: string) => + process.platform === 'darwin' ? v.replace(/\.[A-Za-z0-9@_-]+$/, '') : v; + expect(process.env.LANG).toBe(strip('en_US.UTF-8')); + expect(process.env.LC_CTYPE).toBe(strip('sr_RS.UTF-8')); + expect(process.env.LC_ALL).toBe(strip('C.UTF-8')); }); it('should replace inherited non-UTF-8 locale values with shell UTF-8 values', () => { @@ -226,9 +228,11 @@ describe('shellEnv', () => { initializeShellEnvironment(); - expect(process.env.LANG).toBe('en_US.UTF-8'); - expect(process.env.LC_CTYPE).toBe('en_US.UTF-8'); - expect(process.env.LC_ALL).toBe('en_US.UTF-8'); + // On macOS, encoding suffixes are stripped to prevent ICU crashes. + const expected = process.platform === 'darwin' ? 'en_US' : 'en_US.UTF-8'; + expect(process.env.LANG).toBe(expected); + expect(process.env.LC_CTYPE).toBe(expected); + expect(process.env.LC_ALL).toBe(expected); }); it('should fall back to platform UTF-8 locale when shell exposes no locale values', () => { @@ -286,7 +290,8 @@ describe('shellEnv', () => { initializeShellEnvironment(); - expect(process.env.LANG).toBe('en_US.UTF-8'); + // On macOS, encoding suffixes are stripped to prevent ICU crashes. + expect(process.env.LANG).toBe(process.platform === 'darwin' ? 'en_US' : 'en_US.UTF-8'); expect(process.env.LC_CTYPE).toBeUndefined(); expect(process.env.LC_ALL).toBeUndefined(); }); diff --git a/src/main/utils/shellEnv.ts b/src/main/utils/shellEnv.ts index 0d3632254..4a23c3d37 100644 --- a/src/main/utils/shellEnv.ts +++ b/src/main/utils/shellEnv.ts @@ -12,14 +12,26 @@ import { LOCALE_ENV_VARS, DEFAULT_UTF8_LOCALE, isUtf8Locale } from './locale'; function getFallbackUtf8Locale(): string | undefined { if (process.platform === 'win32') return undefined; - // `C.UTF-8` is a good generic fallback on Linux, but can crash AppKit on - // newer macOS builds when native menus initialize locale-dependent text - // direction. Keep macOS on a concrete UTF-8 locale instead. - if (process.platform === 'darwin') return 'en_US.UTF-8'; + // On macOS, all locales use UTF-8 encoding. Use a bare ICU-compatible + // locale identifier without a POSIX encoding suffix — suffixes like + // `.UTF-8` are not understood by ICU's uloc_getTableStringWithFallback + // on macOS 26+ and cause a null-pointer crash during AppKit menu init. + if (process.platform === 'darwin') return 'en_US'; return DEFAULT_UTF8_LOCALE; } +/** + * On macOS, strips POSIX encoding suffixes (e.g. ".UTF-8") from locale strings + * so that ICU receives a clean locale identifier it can look up without crashing. + * macOS always uses UTF-8, so the suffix carries no information and only causes + * problems with newer ICU versions bundled in macOS 26+. + */ +function sanitizeLocaleForPlatform(locale: string): string { + if (process.platform !== 'darwin') return locale; + return locale.replace(/\.[A-Za-z0-9@_-]+$/, ''); +} + /** * Gets an environment variable from the user's login shell. * This is useful when the app is launched from GUI and doesn't @@ -288,4 +300,17 @@ export function initializeShellEnvironment(): void { } initializeLocaleEnvironment(); + + // Strip POSIX encoding suffixes (e.g. ".UTF-8") from all locale env vars on + // macOS. ICU's uloc_getTableStringWithFallback on macOS 26+ crashes when it + // receives locale strings with encoding suffixes — ICU uses its own tag format + // (e.g. "en_US") without POSIX encoding markers. macOS always uses UTF-8, so + // the suffix carries no useful information and only breaks ICU lookup. + if (process.platform === 'darwin') { + for (const key of LOCALE_ENV_VARS) { + if (process.env[key]) { + process.env[key] = sanitizeLocaleForPlatform(process.env[key]!); + } + } + } }