Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
35 changes: 20 additions & 15 deletions src/main/utils/__tests__/shellEnv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>) => (command: string) => {
// Batched locale call: returns values separated by ---
if (command.includes('echo "---"')) {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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();
});
Expand Down
33 changes: 29 additions & 4 deletions src/main/utils/shellEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]!);
}
}
}
}