From 740ea60c1247879acca73b5711922a582e9d4e99 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:42:05 -0700 Subject: [PATCH] fix(macos): opt out of press-and-hold so held keys repeat (#14746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS routes press-and-hold to the accent picker unless an app sets ApplePressAndHoldEnabled=false for its own bundle, so holding j in vim inserted one character instead of repeating. Orca never set it. Written at most once, and never over an explicit value: `defaults read` is domain-scoped and exits 1 when the key is absent, which is the only way to tell "unset" from a deliberate false — Electron's systemPreferences.getUserDefault reports false for both. A recorded decision in userData keeps a later launch from re-clobbering a user who deletes the key to get the accent picker back. --- src/main/index.ts | 4 + ...s-and-hold-default.defaults-domain.test.ts | 155 +++++++++++ src/main/macos-press-and-hold-default.test.ts | 240 ++++++++++++++++++ src/main/macos-press-and-hold-default.ts | 230 +++++++++++++++++ .../e2e/macos-press-and-hold-startup.spec.ts | 96 +++++++ 5 files changed, 725 insertions(+) create mode 100644 src/main/macos-press-and-hold-default.defaults-domain.test.ts create mode 100644 src/main/macos-press-and-hold-default.test.ts create mode 100644 src/main/macos-press-and-hold-default.ts create mode 100644 tests/e2e/macos-press-and-hold-startup.spec.ts diff --git a/src/main/index.ts b/src/main/index.ts index ecd94696965..c75181f26b2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,6 +3,7 @@ import { existsSync, statSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import os from 'node:os' import { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerMonitor, type Tray } from 'electron' +import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default' import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' import { electronApp, is } from '@electron-toolkit/utils' import { @@ -857,6 +858,9 @@ if (hasSingleInstanceLock) { installDevParentSignalQuit(shouldCoupleToDevParent) // Why: run after configureDevUserDataPath but before app.setName('Orca') (whenReady), which changes the resolved path on case-sensitive filesystems. initDataPath() + // Why here: AppKit reads ApplePressAndHoldEnabled as the process starts, so this is the last + // point that can still matter for this launch; it needs the canonical userData path above. + applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) // Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence. initSessionParseCachePersistence({ filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), diff --git a/src/main/macos-press-and-hold-default.defaults-domain.test.ts b/src/main/macos-press-and-hold-default.defaults-domain.test.ts new file mode 100644 index 00000000000..c44e511a360 --- /dev/null +++ b/src/main/macos-press-and-hold-default.defaults-domain.test.ts @@ -0,0 +1,155 @@ +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + PRESS_AND_HOLD_KEY, + ensureMacPressAndHoldDefault, + interpretDefaultsReadFailure, + readDomainPressAndHoldPreference, + writeDomainPressAndHoldPreference, + type PressAndHoldHost, + type PressAndHoldRecord +} from './macos-press-and-hold-default' + +/** + * Runs against the real `/usr/bin/defaults` on a throwaway Orca-owned domain. + * + * The whole design rests on one claim the mocks cannot make: a domain that has never been written + * is distinguishable from one explicitly set to `false`. Electron's `systemPreferences` cannot tell + * them apart, so if `defaults` could not either, "only write when unset" would be unimplementable. + * + * Every domain used here is a throwaway UUID under Orca's own prefix, deleted along with its plist + * in `afterEach`; the real `com.stablyai.orca` domain is never read or written. + */ + +// Why a real Orca-owned domain shape: the ownership guard rejects anything else, so a fake prefix +// would exercise a different branch than production. +const domains: string[] = [] + +function throwawayDomain(): string { + const domain = `com.stablyai.orca.defaults-domain-test.${randomUUID()}` + domains.push(domain) + return domain +} + +function write(domain: string, value: 'true' | 'false'): void { + execFileSync('/usr/bin/defaults', ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value], { + stdio: 'ignore' + }) +} + +function rawRead(domain: string): string { + return execFileSync('/usr/bin/defaults', ['read', domain, PRESS_AND_HOLD_KEY], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() +} + +function hostFor(domain: string, record: PressAndHoldRecord | null = null): PressAndHoldHost { + return { + platform: 'darwin', + resolveBundleIdentifier: () => domain, + readRecord: () => record, + writeRecord: () => undefined, + readDomainPreference: readDomainPressAndHoldPreference, + writeDomainPreference: writeDomainPressAndHoldPreference, + now: () => new Date().toISOString() + } +} + +describe.skipIf(process.platform !== 'darwin')( + 'press-and-hold against the real defaults binary', + () => { + afterEach(() => { + for (const domain of domains.splice(0)) { + try { + execFileSync('/usr/bin/defaults', ['delete', domain], { stdio: 'ignore' }) + } catch { + // The domain may never have been created; nothing to clean up. + } + // Why the file too: `defaults delete` empties the domain but leaves its plist behind, so + // without this every run litters the developer's ~/Library/Preferences. + rmSync(join(homedir(), 'Library', 'Preferences', `${domain}.plist`), { force: true }) + } + }) + + it('tells an unset key apart from an explicit false', () => { + const unset = throwawayDomain() + const explicitlyFalse = throwawayDomain() + write(explicitlyFalse, 'false') + + expect(readDomainPressAndHoldPreference(unset)).toBe('unset') + expect(readDomainPressAndHoldPreference(explicitlyFalse)).toBe('set') + expect(rawRead(explicitlyFalse)).toBe('0') + }) + + it('reports an explicit true as set', () => { + const domain = throwawayDomain() + write(domain, 'true') + + expect(readDomainPressAndHoldPreference(domain)).toBe('set') + expect(rawRead(domain)).toBe('1') + }) + + it('writes false into a previously unset domain', () => { + const domain = throwawayDomain() + + expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('applied') + expect(rawRead(domain)).toBe('0') + }) + + it('leaves an explicit value untouched, in either direction', () => { + for (const value of ['true', 'false'] as const) { + const domain = throwawayDomain() + write(domain, value) + + expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('kept-user-preference') + expect(rawRead(domain)).toBe(value === 'true' ? '1' : '0') + } + }) + + it('refuses to call a failed probe "unset"', () => { + // A real spawn failure, not a hand-built object: `execFileSync` reports it with no exit status, + // which is precisely what must not be mistaken for "the key does not exist". + let spawnFailure: unknown + try { + execFileSync('/usr/bin/defaults-does-not-exist', ['read'], { stdio: 'ignore' }) + } catch (error) { + spawnFailure = error + } + expect((spawnFailure as { status?: number | null }).status ?? null).toBe(null) + expect(interpretDefaultsReadFailure(spawnFailure)).toBe('unknown') + + let missingKeyFailure: unknown + try { + execFileSync('/usr/bin/defaults', ['read', throwawayDomain(), PRESS_AND_HOLD_KEY], { + stdio: 'ignore' + }) + } catch (error) { + missingKeyFailure = error + } + expect((missingKeyFailure as { status?: number }).status).toBe(1) + expect(interpretDefaultsReadFailure(missingKeyFailure)).toBe('unset') + }) + + it('sees the key disappear again after the user deletes it', () => { + const domain = throwawayDomain() + write(domain, 'false') + execFileSync('/usr/bin/defaults', ['delete', domain, PRESS_AND_HOLD_KEY], { stdio: 'ignore' }) + + expect(readDomainPressAndHoldPreference(domain)).toBe('unset') + // A launch that already decided must not rewrite it, which is what makes the delete stick. + const decided: PressAndHoldRecord = { + version: 1, + decision: 'applied', + domain, + decidedAt: '2026-01-01T00:00:00.000Z' + } + expect(ensureMacPressAndHoldDefault(hostFor(domain, decided))).toBe('already-decided') + expect(readDomainPressAndHoldPreference(domain)).toBe('unset') + }) + } +) diff --git a/src/main/macos-press-and-hold-default.test.ts b/src/main/macos-press-and-hold-default.test.ts new file mode 100644 index 00000000000..ebee4c2145e --- /dev/null +++ b/src/main/macos-press-and-hold-default.test.ts @@ -0,0 +1,240 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ensureMacPressAndHoldDefault, + isOrcaPreferencesDomain, + readBundleIdentifierFromExecutablePath, + type PressAndHoldDecision, + type PressAndHoldHost, + type PressAndHoldRecord +} from './macos-press-and-hold-default' + +const ORCA_DOMAIN = 'com.stablyai.orca' + +type HostOverrides = Partial & { record?: PressAndHoldRecord | null } + +/** A macOS host whose domain has no explicit value — the state every affected user is in. */ +function createHost(overrides: HostOverrides = {}): { + host: PressAndHoldHost + writes: { domain: string; value: boolean }[] + records: PressAndHoldRecord[] +} { + const writes: { domain: string; value: boolean }[] = [] + const records: PressAndHoldRecord[] = [] + let stored = overrides.record ?? null + const host: PressAndHoldHost = { + platform: 'darwin', + resolveBundleIdentifier: () => ORCA_DOMAIN, + readRecord: () => stored, + writeRecord: (record) => { + stored = record + records.push(record) + }, + readDomainPreference: () => 'unset', + writeDomainPreference: (domain, value) => { + writes.push({ domain, value }) + return true + }, + now: () => '2026-08-20T00:00:00.000Z', + ...overrides + } + return { host, writes, records } +} + +function terminalRecord(decision: PressAndHoldDecision): PressAndHoldRecord { + return { version: 1, decision, domain: ORCA_DOMAIN, decidedAt: '2026-01-01T00:00:00.000Z' } +} + +describe('ensureMacPressAndHoldDefault', () => { + it('writes the opt-out when the domain has no explicit value', () => { + const { host, writes, records } = createHost() + + expect(ensureMacPressAndHoldDefault(host)).toBe('applied') + expect(writes).toEqual([{ domain: ORCA_DOMAIN, value: false }]) + expect(records.at(-1)?.decision).toBe('applied') + }) + + describe('platform guard', () => { + for (const platform of ['win32', 'linux'] as const) { + it(`does nothing at all on ${platform}`, () => { + const probe = vi.fn(() => 'unset' as const) + const readRecord = vi.fn(() => null) + const { host, writes, records } = createHost({ + platform, + readDomainPreference: probe, + readRecord + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('not-macos') + // Why assert the reads too: "inert" means no `defaults` subprocess and no userData I/O, + // not merely no write. + expect(probe).not.toHaveBeenCalled() + expect(readRecord).not.toHaveBeenCalled() + expect(writes).toEqual([]) + expect(records).toEqual([]) + }) + } + }) + + describe('respects an explicit user setting', () => { + it('leaves the domain alone when the user already set a value', () => { + // One arm covers both `true` and `false`: the probe deliberately reports only presence, so + // whichever way the user's value points it is theirs. That the probe really does report + // presence for both is pinned against the real binary in the .defaults-domain test. + const { host, writes, records } = createHost({ readDomainPreference: () => 'set' }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('kept-user-preference') + expect(writes).toEqual([]) + expect(records.at(-1)?.decision).toBe('kept-user-preference') + }) + + it('never re-applies after a launch already decided', () => { + for (const decision of ['applied', 'kept-user-preference'] as const) { + const probe = vi.fn(() => 'unset' as const) + const { host, writes } = createHost({ + record: terminalRecord(decision), + readDomainPreference: probe + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided') + expect(probe).not.toHaveBeenCalled() + expect(writes).toEqual([]) + } + }) + + it('stays out of the way after the user deletes the key we wrote', () => { + // The documented way back to the accent picker is `defaults delete`, which returns the + // domain to "unset". Without the recorded decision the next launch would rewrite it. + const { host, writes } = createHost({ + record: terminalRecord('applied'), + readDomainPreference: () => 'unset' + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided') + expect(writes).toEqual([]) + }) + }) + + describe('domains we do not own', () => { + it('skips a bare Electron bundle rather than writing into a shared domain', () => { + const probe = vi.fn(() => 'unset' as const) + const { host, writes, records } = createHost({ + resolveBundleIdentifier: () => 'com.github.Electron', + readDomainPreference: probe + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle') + expect(probe).not.toHaveBeenCalled() + expect(writes).toEqual([]) + expect(records.at(-1)?.domain).toBe('com.github.Electron') + }) + + it('skips when the bundle identifier cannot be resolved', () => { + const { host, writes } = createHost({ resolveBundleIdentifier: () => null }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle') + expect(writes).toEqual([]) + }) + + it('accepts Orca and its channel-scoped bundles, and nothing else', () => { + expect(isOrcaPreferencesDomain('com.stablyai.orca')).toBe(true) + expect(isOrcaPreferencesDomain('com.stablyai.orca.dev')).toBe(true) + expect(isOrcaPreferencesDomain('com.github.Electron')).toBe(false) + // Why: a prefix test without the dot would accept a lookalike bundle id. + expect(isOrcaPreferencesDomain('com.stablyai.orcafake')).toBe(false) + }) + }) + + describe('failures leave the decision open', () => { + it('does not write when the probe cannot tell unset from user-set', () => { + const { host, writes, records } = createHost({ readDomainPreference: () => 'unknown' }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('probe-failed') + expect(writes).toEqual([]) + expect(records.at(-1)?.decision).toBe('probe-failed') + }) + + it('retries on the next launch after a failed write', () => { + const { host, records } = createHost({ writeDomainPreference: () => false }) + expect(ensureMacPressAndHoldDefault(host)).toBe('write-failed') + + const retry = createHost({ record: records.at(-1) }) + expect(ensureMacPressAndHoldDefault(retry.host)).toBe('applied') + expect(retry.writes).toEqual([{ domain: ORCA_DOMAIN, value: false }]) + }) + }) + + it('does not rewrite an unchanged non-terminal record', () => { + const first = createHost({ resolveBundleIdentifier: () => 'com.github.Electron' }) + expect(ensureMacPressAndHoldDefault(first.host)).toBe('foreign-bundle') + + const second = createHost({ + record: first.records.at(-1), + resolveBundleIdentifier: () => 'com.github.Electron' + }) + expect(ensureMacPressAndHoldDefault(second.host)).toBe('foreign-bundle') + expect(second.records).toEqual([]) + }) +}) + +describe('readBundleIdentifierFromExecutablePath', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + function bundleWithPlist(body: string): string { + const root = mkdtempSync(join(tmpdir(), 'orca-press-hold-')) + roots.push(root) + mkdirSync(join(root, 'Orca.app', 'Contents', 'MacOS'), { recursive: true }) + writeFileSync(join(root, 'Orca.app', 'Contents', 'Info.plist'), body) + return join(root, 'Orca.app', 'Contents', 'MacOS', 'Orca') + } + + it('reads CFBundleIdentifier from the plist beside the executable', () => { + const exe = bundleWithPlist( + '\nCFBundleName\nOrca\n' + + 'CFBundleIdentifier\n\tcom.stablyai.orca\n' + ) + + expect(readBundleIdentifierFromExecutablePath(exe)).toBe('com.stablyai.orca') + }) + + it('returns null when the plist is missing or carries no identifier', () => { + expect(readBundleIdentifierFromExecutablePath('/nonexistent/App.app/Contents/MacOS/App')).toBe( + null + ) + expect(readBundleIdentifierFromExecutablePath(bundleWithPlist(''))).toBe( + null + ) + expect( + readBundleIdentifierFromExecutablePath( + bundleWithPlist('CFBundleIdentifier') + ) + ).toBe(null) + }) +}) + +describe('startup wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + it('runs before app.whenReady(), which is the last point AppKit could still see it', () => { + const callIndex = source.indexOf( + 'applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())' + ) + const initDataPathIndex = source.indexOf('initDataPath()') + const readyIndex = source.indexOf('app.whenReady().then(') + + expect(callIndex).toBeGreaterThanOrEqual(0) + expect(readyIndex).toBeGreaterThanOrEqual(0) + // Why after initDataPath: the record lives beside orca-data.json, and the canonical userData + // path is only captured there. + expect(callIndex).toBeGreaterThan(initDataPathIndex) + expect(callIndex).toBeLessThan(readyIndex) + }) +}) diff --git a/src/main/macos-press-and-hold-default.ts b/src/main/macos-press-and-hold-default.ts new file mode 100644 index 00000000000..b4586f7ac6e --- /dev/null +++ b/src/main/macos-press-and-hold-default.ts @@ -0,0 +1,230 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { writeFileAtomically } from './codex-accounts/fs-utils' + +/** + * Turns off the macOS accent picker for Orca's own preferences domain (#14746). + * + * macOS routes press-and-hold to the accent popup unless an app opts out via + * `ApplePressAndHoldEnabled`, so holding `j` in vim inserts one character instead of repeating. + * The key is unset by default, which is why every terminal-hosting Mac app ships this opt-out. + * + * Written once and never again: a user who wants the accent picker back sets + * `defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true` (or deletes the key), and + * the recorded decision below keeps a later launch from overwriting that choice. + * + * AppKit reads the preference while the process starts, so a fresh write lands for the *next* + * launch, not the current one. + */ + +export const PRESS_AND_HOLD_KEY = 'ApplePressAndHoldEnabled' +export const PRESS_AND_HOLD_RECORD_FILE = 'macos-press-and-hold-default.json' +export const PRESS_AND_HOLD_RECORD_VERSION = 1 + +/** Why not `systemPreferences.getUserDefault`: it reads through the whole NSUserDefaults search + * list and is typed non-nullable, so an unset key and an explicit `false` both come back `false` — + * and the system default is unset, so it reports `false` on a Mac where press-and-hold is on. + * `defaults read ` is domain-scoped and exits 1 when the key is absent. */ +const DEFAULTS_BINARY = '/usr/bin/defaults' +const DEFAULTS_TIMEOUT_MS = 5_000 +/** Why: `defaults` exits 1 for "does not exist"; other statuses mean the probe itself failed. */ +const DEFAULTS_MISSING_STATUS = 1 + +const ORCA_BUNDLE_ID = 'com.stablyai.orca' + +export type PressAndHoldDecision = + /** Not macOS — nothing is read or written. */ + | 'not-macos' + /** A previous launch already decided; the domain is never touched again. */ + | 'already-decided' + /** The running bundle is not Orca's (e.g. a bare `Electron.app`), whose domain we do not own. */ + | 'foreign-bundle' + /** `defaults read` could not answer, so we cannot tell an unset key from a user's choice. */ + | 'probe-failed' + /** `defaults write` failed. */ + | 'write-failed' + /** The domain already carries an explicit value; the user's choice stands. */ + | 'kept-user-preference' + /** The key was unset and we wrote `false`. */ + | 'applied' + +/** Decisions about the domain itself. Anything else is a condition that can change by next launch. */ +const TERMINAL_DECISIONS = new Set(['applied', 'kept-user-preference']) + +export type PressAndHoldRecord = { + version: number + decision: PressAndHoldDecision + domain: string | null + decidedAt: string +} + +export type PressAndHoldHost = { + platform: NodeJS.Platform + resolveBundleIdentifier: () => string | null + readRecord: () => PressAndHoldRecord | null + writeRecord: (record: PressAndHoldRecord) => void + readDomainPreference: (domain: string) => 'set' | 'unset' | 'unknown' + writeDomainPreference: (domain: string, value: boolean) => boolean + now: () => string +} + +/** Only Orca's own bundle: an unpackaged run is `com.github.Electron`, shared with every other + * unpackaged Electron app on the machine. */ +export function isOrcaPreferencesDomain(domain: string): boolean { + return domain === ORCA_BUNDLE_ID || domain.startsWith(`${ORCA_BUNDLE_ID}.`) +} + +/** `/Contents/MacOS/` → `/Contents/Info.plist`. */ +export function readBundleIdentifierFromExecutablePath(execPath: string): string | null { + try { + const plist = readFileSync(join(dirname(dirname(execPath)), 'Info.plist'), 'utf8') + const match = /CFBundleIdentifier<\/key>\s*([^<]*)<\/string>/.exec(plist) + const identifier = match?.[1]?.trim() + return identifier ? identifier : null + } catch { + return null + } +} + +/** + * Why this distinction carries the whole design: only a real "does not exist" answer may be read as + * unset. A spawn failure or a timeout leaves `status` unset, and calling that unset would let a + * broken probe overwrite a value the user chose. + */ +export function interpretDefaultsReadFailure(error: unknown): 'unset' | 'unknown' { + return (error as { status?: number | null }).status === DEFAULTS_MISSING_STATUS + ? 'unset' + : 'unknown' +} + +export function readDomainPressAndHoldPreference(domain: string): 'set' | 'unset' | 'unknown' { + try { + execFileSync(DEFAULTS_BINARY, ['read', domain, PRESS_AND_HOLD_KEY], { + encoding: 'utf8', + timeout: DEFAULTS_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'] + }) + return 'set' + } catch (error) { + return interpretDefaultsReadFailure(error) + } +} + +export function writeDomainPressAndHoldPreference(domain: string, value: boolean): boolean { + try { + execFileSync( + DEFAULTS_BINARY, + ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value ? 'true' : 'false'], + { encoding: 'utf8', timeout: DEFAULTS_TIMEOUT_MS, stdio: ['ignore', 'ignore', 'ignore'] } + ) + return true + } catch { + return false + } +} + +export function pressAndHoldRecordPath(userDataPath: string): string { + return join(userDataPath, PRESS_AND_HOLD_RECORD_FILE) +} + +function parseRecord(raw: string): PressAndHoldRecord | null { + const parsed = JSON.parse(raw) as Partial + if (parsed.version !== PRESS_AND_HOLD_RECORD_VERSION || typeof parsed.decision !== 'string') { + return null + } + return { + version: PRESS_AND_HOLD_RECORD_VERSION, + decision: parsed.decision as PressAndHoldDecision, + domain: typeof parsed.domain === 'string' ? parsed.domain : null, + decidedAt: typeof parsed.decidedAt === 'string' ? parsed.decidedAt : '' + } +} + +/** + * Apply Orca's press-and-hold default at most once, leaving any explicit user value alone. + * + * Returns the decision so startup can log it; the same value is persisted for support triage. + */ +export function ensureMacPressAndHoldDefault(host: PressAndHoldHost): PressAndHoldDecision { + // Why first and without touching disk: Windows and Linux must do no work at all here. + if (host.platform !== 'darwin') { + return 'not-macos' + } + + const previous = host.readRecord() + if (previous && TERMINAL_DECISIONS.has(previous.decision)) { + return 'already-decided' + } + + const record = (decision: PressAndHoldDecision, domain: string | null): PressAndHoldDecision => { + // Why skip an unchanged rewrite: the non-terminal decisions are re-evaluated every launch. + if (!previous || previous.decision !== decision || previous.domain !== domain) { + host.writeRecord({ + version: PRESS_AND_HOLD_RECORD_VERSION, + decision, + domain, + decidedAt: host.now() + }) + } + return decision + } + + const domain = host.resolveBundleIdentifier() + if (!domain || !isOrcaPreferencesDomain(domain)) { + return record('foreign-bundle', domain) + } + + const existing = host.readDomainPreference(domain) + if (existing === 'unknown') { + return record('probe-failed', domain) + } + if (existing === 'set') { + return record('kept-user-preference', domain) + } + if (!host.writeDomainPreference(domain, false)) { + return record('write-failed', domain) + } + return record('applied', domain) +} + +/** Why log: silently rewriting a macOS default for the user's login account should leave a trace, + * and the failure paths are otherwise invisible. The quiet decisions are the steady state. */ +const REPORTED_DECISIONS: Partial> = { + applied: `set ${PRESS_AND_HOLD_KEY}=false so held keys repeat; takes effect next launch`, + 'probe-failed': `could not read ${PRESS_AND_HOLD_KEY}; leaving it alone`, + 'write-failed': `could not write ${PRESS_AND_HOLD_KEY}; held keys will not repeat` +} + +/** Wires {@link ensureMacPressAndHoldDefault} to the real bundle, `defaults`, and userData. */ +export function applyMacPressAndHoldDefaultAtStartup(userDataPath: string): PressAndHoldDecision { + const recordPath = pressAndHoldRecordPath(userDataPath) + const decision = ensureMacPressAndHoldDefault({ + platform: process.platform, + resolveBundleIdentifier: () => readBundleIdentifierFromExecutablePath(process.execPath), + readRecord: () => { + try { + return parseRecord(readFileSync(recordPath, 'utf8')) + } catch { + return null + } + }, + writeRecord: (record) => { + try { + // Why: this runs before `ready`, and Electron has not necessarily created userData yet. + mkdirSync(userDataPath, { recursive: true }) + writeFileAtomically(recordPath, `${JSON.stringify(record, null, 2)}\n`) + } catch { + // Best-effort: an unwritten record only costs one repeated probe next launch. + } + }, + readDomainPreference: readDomainPressAndHoldPreference, + writeDomainPreference: writeDomainPressAndHoldPreference, + now: () => new Date().toISOString() + }) + const reported = REPORTED_DECISIONS[decision] + if (reported) { + console.log(`[press-and-hold] ${reported}`) + } + return decision +} diff --git a/tests/e2e/macos-press-and-hold-startup.spec.ts b/tests/e2e/macos-press-and-hold-startup.spec.ts new file mode 100644 index 00000000000..c2562c6e0b3 --- /dev/null +++ b/tests/e2e/macos-press-and-hold-startup.spec.ts @@ -0,0 +1,96 @@ +/** + * Startup wiring for the macOS press-and-hold opt-out (#14746). + * + * What this can prove: the routine runs during startup, resolves the *real* running bundle's + * identifier from its Info.plist, records a decision, and stays completely inert off macOS. + * + * What it cannot prove: that held keys repeat. Key repeat versus the accent picker is decided by + * AppKit from a preference it reads as the process starts, so observing it needs a relaunch and a + * physically held key — neither of which Playwright's synthesized CDP key events go through. + * The preference semantics the fix depends on are pinned against the real `/usr/bin/defaults` in + * src/main/macos-press-and-hold-default.defaults-domain.test.ts. + * + * The E2E app runs unpackaged, so its bundle identifier is Electron's own rather than Orca's. That + * is the ownership guard's case, and asserting it here is the point: `defaults` resolves the user's + * real home regardless of the harness's HOME isolation, so a run that wrote anything would be + * writing into a domain shared with every other unpackaged Electron app on the machine. + */ +import type { ElectronApplication } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' + +test.use({ seedTestRepo: false }) + +type StartupState = { + recordRaw: string | null + bundleIdentifier: string | null + execPath: string +} + +async function readPressAndHoldStartupState( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(({ app }) => { + const nodeFs = process.getBuiltinModule('node:fs') + const nodePath = process.getBuiltinModule('node:path') + const recordPath = nodePath.join(app.getPath('userData'), 'macos-press-and-hold-default.json') + const read = (file: string): string | null => { + try { + return nodeFs.readFileSync(file, 'utf8') + } catch { + return null + } + } + // Why resolved here rather than hardcoded: this is the independent half of the cross-check — + // the spec derives the identifier from the launched bundle without reusing product code. + const plist = read( + nodePath.join(nodePath.dirname(nodePath.dirname(process.execPath)), 'Info.plist') + ) + const match = plist + ? /CFBundleIdentifier<\/key>\s*([^<]*)<\/string>/.exec(plist) + : null + return { + recordRaw: read(recordPath), + bundleIdentifier: match ? match[1].trim() : null, + execPath: process.execPath + } + }) +} + +test.describe('macOS press-and-hold default', () => { + test.skip(process.platform !== 'darwin', 'macOS-only startup routine') + + test('records a decision for the bundle it is actually running from', async ({ electronApp }) => { + const state = await readPressAndHoldStartupState(electronApp) + + expect(state.recordRaw, 'startup must leave a decision record').not.toBeNull() + const record = JSON.parse(state.recordRaw!) as { + version: number + decision: string + domain: string | null + } + + expect(record.version).toBe(1) + expect(state.bundleIdentifier).not.toBeNull() + expect(record.domain).toBe(state.bundleIdentifier) + + const ownsDomain = + record.domain === 'com.stablyai.orca' || record.domain!.startsWith('com.stablyai.orca.') + if (ownsDomain) { + // A packaged or dev-identity bundle: the write path is live and must have settled. + expect(['applied', 'kept-user-preference']).toContain(record.decision) + } else { + // The unpackaged harness. Anything but a refusal means Orca wrote into a foreign domain. + expect(record.decision).toBe('foreign-bundle') + } + }) +}) + +test.describe('off macOS', () => { + test.skip(process.platform === 'darwin', 'covers the non-macOS branch') + + test('writes nothing at all', async ({ electronApp }) => { + const state = await readPressAndHoldStartupState(electronApp) + + expect(state.recordRaw).toBeNull() + }) +})