diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 486f213ad5e..1f6b92c6743 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -1,6 +1,10 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' import { app, ipcMain } from 'electron' import { isWslAvailable } from '../wsl' +const execFileAsync = promisify(execFile) + export type AppRuntimeFlags = { /** Whether the persistent terminal daemon was actually started this session. * The renderer compares this against the current setting to decide whether @@ -42,6 +46,52 @@ export function registerAppHandlers(): void { ipcMain.handle('wsl:isAvailable', (): boolean => isWslAvailable()) + // Why: ABC, Polish Pro, US Extended, ABC Extended, and every CJK Roman + // IME all report a US-QWERTY base layer to navigator.keyboard.getLayoutMap() + // — the layout-fingerprint probe in the renderer therefore classifies + // them as 'us' and flips macOptionIsMeta=true, silently swallowing every + // Option+letter composition (#1205: Option+A → å / ą is dropped). The + // macOS-shipped `com.apple.HIToolbox` preference + // `AppleCurrentKeyboardLayoutInputSourceID` names the actual layout + // (e.g. `com.apple.keylayout.ABC` vs `com.apple.keylayout.US`), which + // the renderer uses as an authoritative override. Non-Darwin platforms + // have no equivalent and return null so the fingerprint stays the only + // signal. + // + // Why `defaults read` (via execFileSync) and not systemPreferences + // .getUserDefault: getUserDefault only reads from NSGlobalDomain and the + // current app's own domain. The keyboard layout ID lives in the + // `com.apple.HIToolbox` domain, which getUserDefault cannot reach — + // observed to return null even when the preference is set. The `defaults` + // CLI reads any domain and is the same mechanism Apple documents for + // this value. + ipcMain.handle('app:getKeyboardInputSourceId', async (): Promise => { + if (process.platform !== 'darwin') { + return null + } + try { + // Why: async so the probe never blocks the main-process event loop. + // The probe re-runs on every window focus-in (see option-as-alt-probe.ts), + // and a blocking execFileSync would briefly stall unrelated IPC each + // time the user Alt-Tabbed back into the app. + const { stdout } = await execFileAsync( + '/usr/bin/defaults', + ['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'], + // Why: short timeout so a wedged defaults binary (corporate-managed + // config, sandbox policy, …) never holds the handle indefinitely. + // Fall through to the fingerprint on timeout. + { encoding: 'utf8', timeout: 500 } + ) + const trimmed = stdout.trim() + return trimmed.length > 0 ? trimmed : null + } catch { + // Why: defaults exits non-zero when the key is absent (first boot + // before any input-source interaction), or when sandboxed. Treat + // that as "no signal" — the fingerprint still runs as fallback. + return null + } + }) + ipcMain.handle('app:relaunch', () => { // Why: small delay lets the renderer finish painting any "Restarting…" // UI state before the window tears down. `app.relaunch()` schedules a diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1b0e47358b7..daedae535ad 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -288,6 +288,12 @@ export type AppApi = { /** Relaunches the app via Electron's app.relaunch() + app.exit(0). Used * by the "Restart now" button on the Experimental settings pane. */ relaunch: () => Promise + /** Returns the macOS `AppleCurrentKeyboardLayoutInputSourceID` when + * available (e.g. `com.apple.keylayout.PolishPro`). Used by the + * keyboard-layout probe to distinguish layouts whose base layer matches + * US QWERTY but whose Option layer composes characters (issue #1205). + * Returns null on non-Darwin platforms or when the defaults read fails. */ + getKeyboardInputSourceId: () => Promise } export type PreloadApi = { diff --git a/src/preload/index.ts b/src/preload/index.ts index 8f972c6b152..07f90b263cf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -178,7 +178,14 @@ const api = { ipcRenderer.invoke('app:getRuntimeFlags'), consumeDaemonTransitionNotice: (): Promise<{ killedCount: number } | null> => ipcRenderer.invoke('app:consumeDaemonTransitionNotice'), - relaunch: (): Promise => ipcRenderer.invoke('app:relaunch') + relaunch: (): Promise => ipcRenderer.invoke('app:relaunch'), + // Why: on macOS this returns AppleCurrentKeyboardLayoutInputSourceID so + // the renderer's keyboard-layout probe can distinguish Polish Pro / US + // Extended / ABC Extended / IME Roman modes from plain US QWERTY (see + // src/renderer/src/lib/keyboard-layout/input-source-id.ts, issue #1205). + // Returns null on non-Darwin or when the defaults read fails. + getKeyboardInputSourceId: (): Promise => + ipcRenderer.invoke('app:getKeyboardInputSourceId') }, wsl: { diff --git a/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts b/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts new file mode 100644 index 00000000000..407aacdb98d --- /dev/null +++ b/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { classifyInputSourceId } from './input-source-id' + +describe('classifyInputSourceId', () => { + it('returns "unknown" for nullish input so the caller falls back to the fingerprint', () => { + expect(classifyInputSourceId(null)).toBe('unknown') + expect(classifyInputSourceId(undefined)).toBe('unknown') + expect(classifyInputSourceId('')).toBe('unknown') + }) + + it('allowlists plain US Standard as meta', () => { + expect(classifyInputSourceId('com.apple.keylayout.US')).toBe('meta') + }) + + it('allowlists US International PC as meta', () => { + expect(classifyInputSourceId('com.apple.keylayout.USInternational-PC')).toBe('meta') + }) + + it('is case-insensitive on the allowlist (defaults differ between macOS versions)', () => { + expect(classifyInputSourceId('COM.APPLE.KEYLAYOUT.US')).toBe('meta') + expect(classifyInputSourceId('com.apple.keylayout.us')).toBe('meta') + }) + + it('classifies ABC as compose (the user-reported Option+A → å repro)', () => { + // ABC looks US on the base layer but composes Option+A → å. Pre-fix, + // the fingerprint alone drove the decision and flipped + // macOptionIsMeta=true, silently swallowing the composition. + expect(classifyInputSourceId('com.apple.keylayout.ABC')).toBe('compose') + }) + + it('classifies Polish Pro as compose (#1205)', () => { + expect(classifyInputSourceId('com.apple.keylayout.PolishPro')).toBe('compose') + }) + + it('classifies US Extended and ABC Extended as compose', () => { + expect(classifyInputSourceId('com.apple.keylayout.USExtended')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.ABCExtended')).toBe('compose') + }) + + it('classifies every other Apple-shipped layout as compose (default-deny)', () => { + // Matches Ghostty: only US and USInternational-PC are allowlisted; + // everything else (Dvorak, Colemak, German, French, Turkish, Spanish, + // Swedish, every CJK Roman IME) falls back to compose. + expect(classifyInputSourceId('com.apple.keylayout.Dvorak')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.Colemak')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.German')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.French')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.Turkish-QWERTY')).toBe('compose') + expect(classifyInputSourceId('com.apple.inputmethod.Kotoeri.Roman')).toBe('compose') + expect(classifyInputSourceId('com.apple.inputmethod.TCIM.Pinyin')).toBe('compose') + expect(classifyInputSourceId('com.apple.inputmethod.Korean.2SetKorean')).toBe('compose') + }) + + it('does not prefix-leak the US allowlist into extended variants', () => { + // `com.apple.keylayout.US` must not silently allowlist `USExtended`. + // The matcher is full-ID equality (case-insensitive), not prefix. + expect(classifyInputSourceId('com.apple.keylayout.USExtended')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.US.variant')).toBe('compose') + }) +}) diff --git a/src/renderer/src/lib/keyboard-layout/input-source-id.ts b/src/renderer/src/lib/keyboard-layout/input-source-id.ts new file mode 100644 index 00000000000..51c1e821f7c --- /dev/null +++ b/src/renderer/src/lib/keyboard-layout/input-source-id.ts @@ -0,0 +1,80 @@ +/** + * Classifier for macOS's `AppleCurrentKeyboardLayoutInputSourceID`. + * + * Why this exists alongside detect-option-as-alt: the layout-fingerprint + * probe (`detectOptionAsAltFromLayoutMap`) inspects `navigator.keyboard + * .getLayoutMap()`, which only surfaces the base (unshifted) layer. Many + * macOS layouts keep a US-identical base layer but repurpose the Option + * layer for dead-key composition — ABC (Option+A = å), Polish Pro + * (Option+A = ą), US Extended, ABC Extended, and the CJK Roman IMEs all + * share this trap. The fingerprint classifies them as `'us'`, the + * effective setting resolves to `'true'`, xterm's `macOptionIsMeta` + * turns on, and every Option+letter keystroke is silently translated to + * an Esc+letter readline chord — so typing å, ą, ï, etc., fails with no + * visible feedback (issue #1205). + * + * The only layouts where Option-as-Meta is the right default are plain + * US Standard and US-International-PC — matching Ghostty's + * `detectOptionAsAlt` (~/projects/ghostty/src/input/keyboard.zig:25-57 + * + ~/projects/ghostty/macos/Sources/Helpers/KeyboardLayout.swift, + * which whitelists only `com.apple.keylayout.US` and + * `com.apple.keylayout.USInternational-PC`). + * + * When the main-process IPC returns a non-null ID, this classifier is + * authoritative: `'meta'` → Option-as-Meta is safe; `'compose'` → + * Option must compose. The fingerprint probe is only consulted when no + * ID is available (non-Darwin, sandboxed defaults, IPC failure). + */ + +/** + * Input source IDs where Option-as-Meta is the correct default. The + * shipped US Standard and US-International-PC layouts are the only + * Apple layouts that don't use Option for composition. Everything else + * — including ABC, Polish Pro, US Extended, ABC Extended, every + * international layout, every CJK Roman IME — composes via Option and + * must stay `'false'`. + * + * Matching is case-insensitive full-ID. We deliberately do NOT prefix- + * match here: `com.apple.keylayout.US` must not silently allowlist + * `com.apple.keylayout.USExtended`. + */ +const META_INPUT_SOURCE_IDS: readonly string[] = [ + 'com.apple.keylayout.us', + 'com.apple.keylayout.usinternational-pc' +] + +export type InputSourceOverride = + /** Option-as-Meta is safe on this input source. Resolves to `'us'` + * for `effectiveMacOptionAsAlt`. */ + | 'meta' + /** Option composes layout characters on this input source. Resolves + * to `'non-us'` so `macOptionIsMeta` stays off and compositions like + * Option+A → å / ą reach the shell. */ + | 'compose' + /** No macOS input source ID available (non-Darwin, IPC failure, + * sandboxed defaults). The caller should fall back to the layout + * fingerprint. */ + | 'unknown' + +export function classifyInputSourceId(id: string | null | undefined): InputSourceOverride { + if (!id) { + return 'unknown' + } + const normalized = id.toLowerCase() + for (const allowed of META_INPUT_SOURCE_IDS) { + if (normalized === allowed) { + return 'meta' + } + } + // Why: any other macOS input source ID composes via Option. This + // includes ABC (not to be confused with US), Polish Pro, US Extended, + // ABC Extended, every international layout, Dvorak, Colemak, and + // every CJK Roman IME. Forcing `'compose'` matches Ghostty's + // allowlist-only behavior and prevents the #1205-style silent-swallow + // bug from recurring for any future Apple-shipped layout. + return 'compose' +} + +/** Test-only: exported so tests can assert the allowlist without + * reimporting the private constant. */ +export const __metaInputSourceIdsForTests: readonly string[] = META_INPUT_SOURCE_IDS diff --git a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts index 3a9db81cc91..9af8ce323bb 100644 --- a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts +++ b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts @@ -170,4 +170,88 @@ describe('createOptionAsAltProbe', () => { // No further calls after dispose. expect(listener).not.toHaveBeenCalled() }) + + it('forces non-us when the input source ID is not on the Option-as-Meta allowlist (#1205)', async () => { + // ABC and Polish Pro both report a US-identical base layer to + // getLayoutMap(); without the input-source override they would classify + // as 'us' → macOptionIsMeta=true and swallow every Option+letter + // composition (Option+A → å on ABC, ą on Polish Pro). + for (const id of ['com.apple.keylayout.ABC', 'com.apple.keylayout.PolishPro']) { + const win = makeMockWindow(US_MAP) + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => id + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('non-us') + probe.dispose() + } + }) + + it('resolves to us when the input source ID is plain US (allowlist match)', async () => { + const win = makeMockWindow(US_MAP) + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => 'com.apple.keylayout.US' + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + probe.dispose() + }) + + it('trusts the input source ID over the fingerprint even when the fingerprint says us', async () => { + // Pre-fix: the fingerprint's 'us' verdict was authoritative and the + // macOS ID was ignored, so Turkish-F (which reports US-identical on + // several keys) plus any US-like fingerprint flipped + // macOptionIsMeta=true. Now the ID overrides. + const win = makeMockWindow(US_MAP) + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => 'com.apple.keylayout.German' + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('non-us') + probe.dispose() + }) + + it('falls back to the fingerprint when the input-source reader returns null (non-Darwin)', async () => { + const win = makeMockWindow(US_MAP) + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => null + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + probe.dispose() + }) + + it('falls back to the fingerprint when the input-source reader throws', async () => { + const win = makeMockWindow(TURKISH_MAP) + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => { + throw new Error('ipc unavailable') + } + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('non-us') + probe.dispose() + }) + + it('re-probes the input source ID on focus-in so mid-session layout switches are picked up', async () => { + // Simulate: user boots on US, flips to ABC via the Input Source menu, + // Orca regains focus. Fingerprint stays US the whole time; the + // input-source override is what notices the switch. + let activeInputSourceId: string | null = 'com.apple.keylayout.US' + const win = makeMockWindow(US_MAP) + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => activeInputSourceId + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + + activeInputSourceId = 'com.apple.keylayout.ABC' + win.fireFocus() + // Let the focus-triggered probe resolve. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(probe.getCurrent()).toBe('non-us') + probe.dispose() + }) }) diff --git a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts index 01bea4010c8..cfad8c55407 100644 --- a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts +++ b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts @@ -13,12 +13,24 @@ * reliable proxy. The only missed case is a layout change triggered by a * key pressed while Orca is focused (e.g. a Karabiner rule), which is * exceedingly rare and self-heals on the next blur/focus cycle. + * + * Why two signals (input source ID + fingerprint): the fingerprint can + * only see the base (unshifted) layer, which is identical to US QWERTY + * on a large set of Apple-shipped layouts — ABC, Polish Pro, US + * Extended, ABC Extended, and every CJK Roman IME all trap on it. They + * repurpose Option for dead-key composition (Option+A → å / ą), so + * trusting the fingerprint alone makes macOptionIsMeta=true and + * silently swallows those characters (issue #1205). On macOS we treat + * the input source ID as authoritative and only fall back to the + * fingerprint when the ID is unavailable (non-Darwin, sandboxed + * defaults, IPC failure). See ./input-source-id.ts for the allowlist. */ import { detectOptionAsAltFromLayoutMap, type DetectedLayoutCategory, type LayoutMapLike } from './detect-option-as-alt' +import { classifyInputSourceId } from './input-source-id' type NavigatorWithKeyboard = Navigator & { keyboard?: { @@ -28,6 +40,8 @@ type NavigatorWithKeyboard = Navigator & { type Listener = (category: DetectedLayoutCategory) => void +type InputSourceIdReader = () => Promise + export type OptionAsAltProbe = { /** Current detected category. Starts `'unknown'` until the first probe * resolves (within a few ms of app boot); listeners fire on every @@ -40,10 +54,43 @@ export type OptionAsAltProbe = { dispose: () => void } -export function createOptionAsAltProbe(win: Window = window): OptionAsAltProbe { +type CreateProbeOptions = { + /** Injectable reader for the macOS input source ID. Defaults to the + * preload `window.api.app.getKeyboardInputSourceId` when available. + * Tests pass a stub to exercise the compose override deterministically. */ + readInputSourceId?: InputSourceIdReader +} + +function defaultInputSourceIdReader(): InputSourceIdReader { + return async () => { + const api = ( + globalThis as { + window?: { api?: { app?: { getKeyboardInputSourceId?: () => Promise } } } + } + ).window?.api + const reader = api?.app?.getKeyboardInputSourceId + if (!reader) { + return null + } + try { + return await reader() + } catch { + // Why: the IPC can transiently reject during main-process teardown + // (e.g. app quitting mid-probe). Treat as no signal so the + // fingerprint remains the sole input. + return null + } + } +} + +export function createOptionAsAltProbe( + win: Window = window, + options: CreateProbeOptions = {} +): OptionAsAltProbe { let current: DetectedLayoutCategory = 'unknown' const listeners = new Set() let disposed = false + const readInputSourceId = options.readInputSourceId ?? defaultInputSourceIdReader() const notify = (next: DetectedLayoutCategory): void => { if (next === current) { @@ -65,6 +112,40 @@ export function createOptionAsAltProbe(win: Window = window): OptionAsAltProbe { } const nav = win.navigator as NavigatorWithKeyboard const keyboard = nav?.keyboard + + // Why: read the input-source ID first. On macOS this resolves to a + // concrete ID (e.g. com.apple.keylayout.ABC); on every other platform + // it resolves to null and we fall through to the fingerprint. + let inputSourceId: string | null = null + try { + inputSourceId = await readInputSourceId() + } catch { + // Treat errors as no signal — the fingerprint still runs below. + inputSourceId = null + } + + if (disposed) { + return + } + + // Why: when macOS returns a concrete input source ID, it's authoritative. + // The fingerprint can only see the base (unshifted) layer, which is + // US-identical on ABC, Polish Pro, US Extended, ABC Extended, and every + // CJK Roman IME — so trusting it flips macOptionIsMeta=true on all of + // them and silently swallows Option+letter compositions (#1205). The + // allowlist matches Ghostty: only com.apple.keylayout.US and + // com.apple.keylayout.USInternational-PC get Option-as-Meta; everything + // else composes via Option. + const override = classifyInputSourceId(inputSourceId) + if (override === 'meta') { + notify('us') + return + } + if (override === 'compose') { + notify('non-us') + return + } + if (!keyboard?.getLayoutMap) { // Non-Chromium or Electron stripped of the Keyboard API. Stay at // 'unknown' → terminal defaults to 'false' (safe for non-US).