mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
fix(macos): opt out of press-and-hold so held keys repeat (#14746)
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.
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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<PressAndHoldHost> & { 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(
|
||||
'<plist><dict>\n<key>CFBundleName</key>\n<string>Orca</string>\n' +
|
||||
'<key>CFBundleIdentifier</key>\n\t<string>com.stablyai.orca</string>\n</dict></plist>'
|
||||
)
|
||||
|
||||
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('<plist><dict/></plist>'))).toBe(
|
||||
null
|
||||
)
|
||||
expect(
|
||||
readBundleIdentifierFromExecutablePath(
|
||||
bundleWithPlist('<key>CFBundleIdentifier</key><string></string>')
|
||||
)
|
||||
).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)
|
||||
})
|
||||
})
|
||||
@@ -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 <domain> <key>` 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<PressAndHoldDecision>(['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}.`)
|
||||
}
|
||||
|
||||
/** `<bundle>/Contents/MacOS/<exe>` → `<bundle>/Contents/Info.plist`. */
|
||||
export function readBundleIdentifierFromExecutablePath(execPath: string): string | null {
|
||||
try {
|
||||
const plist = readFileSync(join(dirname(dirname(execPath)), 'Info.plist'), 'utf8')
|
||||
const match = /<key>CFBundleIdentifier<\/key>\s*<string>([^<]*)<\/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<PressAndHoldRecord>
|
||||
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<Record<PressAndHoldDecision, string>> = {
|
||||
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
|
||||
}
|
||||
@@ -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<StartupState> {
|
||||
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
|
||||
? /<key>CFBundleIdentifier<\/key>\s*<string>([^<]*)<\/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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user