mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* fix(terminal): restore Polish / US-Extended Option+letter composition (#1205) The macOptionAsAlt layout probe only inspects `navigator.keyboard.getLayoutMap()`, which surfaces the base (unshifted) layer. Polish Pro, US Extended, ABC Extended, and the CJK Roman IMEs all ship a US-QWERTY base layer and put their dead-key composition on the Option layer — invisible to the fingerprint. The probe therefore classified them as `'us'`, flipped `macOptionIsMeta=true`, and xterm.js translated every Option+letter into an Esc+letter readline chord, silently swallowing ą, ć, ę, ł, etc. Adds a main-process IPC that reads macOS's `AppleCurrentKeyboardLayoutInputSourceID` (the same signal Ghostty uses via TISCopyCurrentKeyboardInputSource) and an explicit denylist of input source IDs whose Option layer composes. When the active ID is on the denylist, the probe forces `'non-us'` so `effectiveMacOptionAsAlt` resolves to `'false'` and Option+letter compositions reach the shell. Co-authored-by: Orca <help@stably.ai> * fix(terminal): widen Option+letter composition fix to ABC + all non-US layouts My earlier attempt at #1205 missed the broader bug: Option+A wasn't working on the plain "ABC" layout either (Option+A → å got swallowed). Two corrections: 1. systemPreferences.getUserDefault only reads NSGlobalDomain / the app's own domain. AppleCurrentKeyboardLayoutInputSourceID lives in com.apple.HIToolbox, so the previous IPC returned null on every machine. Switched to execFileSync('/usr/bin/defaults') with a 500ms timeout — the same mechanism Apple documents — so the real ID reaches the renderer. Verified live: the IPC now returns `com.apple.keylayout.ABC` instead of `null`. 2. The denylist approach was too narrow. macOS ships many layouts that share a US-identical base layer but compose via Option (ABC, Polish Pro, US Extended, ABC Extended, every CJK Roman IME). Flipped to an allowlist matching Ghostty: only com.apple.keylayout.US and com.apple.keylayout.USInternational-PC get Option-as-Meta; everything else defaults to compose. When the IPC returns a concrete ID, the classifier is authoritative and the layout fingerprint is skipped; the fingerprint only runs as a fallback on non-Darwin / IPC failure. Verified live in dev: on ABC, Option+A now produces å in the terminal. Co-authored-by: Orca <help@stably.ai> * fix(ipc): read keyboard input source ID asynchronously Avoids blocking the main-process event loop on every focus-in probe (up to 500ms). Same behavior, same timeout — just non-blocking. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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<string | null> => {
|
||||
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
|
||||
|
||||
@@ -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<void>
|
||||
/** 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<string | null>
|
||||
}
|
||||
|
||||
export type PreloadApi = {
|
||||
|
||||
@@ -178,7 +178,14 @@ const api = {
|
||||
ipcRenderer.invoke('app:getRuntimeFlags'),
|
||||
consumeDaemonTransitionNotice: (): Promise<{ killedCount: number } | null> =>
|
||||
ipcRenderer.invoke('app:consumeDaemonTransitionNotice'),
|
||||
relaunch: (): Promise<void> => ipcRenderer.invoke('app:relaunch')
|
||||
relaunch: (): Promise<void> => 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<string | null> =>
|
||||
ipcRenderer.invoke('app:getKeyboardInputSourceId')
|
||||
},
|
||||
|
||||
wsl: {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string | null>
|
||||
|
||||
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<string | null> } } }
|
||||
}
|
||||
).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<Listener>()
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user