From 58232cd7974620dbe7347f8a93291e8d5a0ad937 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Thu, 27 Aug 2026 13:41:01 -0700
Subject: [PATCH 1/2] Allow drag to reorder tabs on floating workspace (#16828)
* Enable drag-to-reorder for floating workspace tabs
- Wrap tab bar with FloatingWorkspaceTabDragContext to reuse workspace
tab-drag-split logic and gesture model
- Disable sensors while panel is closed to avoid DndContext conflicts
with main workspace
- Extract isFloatingTerminalDragTarget to separate module for clarity
- Add tests for reorder behavior and titlebar drag-target detection
* Add client-hosted row support to floating panel drag-to-reorder
- Include `[data-client-hosted-browser-row-id]` in no-drag selectors alongside other tab types
- Broaden type check from HTMLElement to Element for better SVG support
- Add test coverage for client-hosted rows and SVG icon interactions
* Improve floating terminal drag target detection with Element safety chec
Handle undefined Element in non-DOM contexts by explicitly checking for its
existence before type-checking. Clarify the logic by replacing double negatives
with explicit null comparison, making the intent clearer and the code more
resilient.
---
...oatingTerminalPanel.tab-lifecycle.test.tsx | 26 +++
.../FloatingTerminalPanel.tsx | 12 +-
.../FloatingWorkspaceTabDragContext.tsx | 49 +++++
.../floating-terminal-titlebar-drag-target.ts | 13 ++
.../floating-workspace-tab-reorder.test.ts | 204 ++++++++++++++++++
5 files changed, 296 insertions(+), 8 deletions(-)
create mode 100644 src/renderer/src/components/floating-terminal/FloatingWorkspaceTabDragContext.tsx
create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts
create mode 100644 src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts
diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx
index 2bb123cc19f..b67dba62e06 100644
--- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx
+++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx
@@ -499,3 +499,29 @@ describe('FloatingTerminalPanel close behavior', () => {
expect(mocks.closeTab).not.toHaveBeenCalledWith('tab-c')
})
})
+
+describe('FloatingTerminalPanel tab drag wiring', () => {
+ beforeEach(setupFloatingTerminalPanelTest)
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('hosts the tab strip in a drag context so floating tabs can be reordered', async () => {
+ setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })])
+
+ const element = await renderPanel(true)
+ const dragContext = findByTypeName(element, 'FloatingWorkspaceTabDragContext')
+
+ expect(dragContext.props.enabled).toBe(true)
+ expect(findByTypeName(dragContext.props.children, 'TabBar')).toBeDefined()
+ })
+
+ it('leaves the drag context inactive while the closed panel stays mounted', async () => {
+ setFloatingTabs([makeTab({ id: 'tab-1' })])
+
+ const element = await renderPanel(false)
+
+ expect(findByTypeName(element, 'FloatingWorkspaceTabDragContext').props.enabled).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
index ca6b25f6e07..94bdc23151a 100644
--- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
+++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
@@ -90,6 +90,8 @@ import type { Tab } from '../../../../shared/tab-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution'
import { FloatingBrowserSlot } from './FloatingBrowserSlot'
+import { FloatingWorkspaceTabDragContext } from './FloatingWorkspaceTabDragContext'
+import { isFloatingTerminalDragTarget } from './floating-terminal-titlebar-drag-target'
import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog'
import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles'
import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls'
@@ -153,8 +155,6 @@ type FloatingPanelShortcutResolution =
| { kind: 'index'; index: number }
| { kind: 'chrome'; action: KeybindingActionId }
-const FLOATING_TERMINAL_NO_DRAG_SELECTOR =
- 'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]'
const FLOATING_TERMINAL_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]'
type FloatingTerminalPanelBoundsState = {
@@ -163,10 +163,6 @@ type FloatingTerminalPanelBoundsState = {
source: FloatingTerminalPanelBoundsSource
}
-function isFloatingTerminalDragTarget(target: EventTarget): boolean {
- return !(target instanceof HTMLElement && target.closest(FLOATING_TERMINAL_NO_DRAG_SELECTOR))
-}
-
function readInitialPanelBounds(): FloatingTerminalPanelBoundsState {
const defaultCommittedBounds = getDefaultFloatingTerminalCommittedBounds()
const defaultRenderedBounds = getDefaultFloatingTerminalBounds()
@@ -1825,7 +1821,7 @@ export function FloatingTerminalPanel({
onPointerCancel={handleDragEnd}
onDoubleClick={handleTitlebarDoubleClick}
>
-
+
-
+
+}): React.JSX.Element {
+ const dragSplit = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled })
+
+ return (
+
+
+
+ {/* Why cloneElement over a render prop: the tab bar stays a plain child element, which keeps the panel's tree shallow-walkable. */}
+ {cloneElement(children, { hoveredTabInsertion: dragSplit.hoveredTabInsertion })}
+
+ {/* Why: the source tab stays anchored in the overflow-hidden strip; this ghost is what follows the cursor. */}
+
+ {dragSplit.activeDrag ? : null}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts b/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts
new file mode 100644
index 00000000000..371dea745d5
--- /dev/null
+++ b/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts
@@ -0,0 +1,13 @@
+/** Interactive chrome living inside the draggable titlebar. `[data-tab-id]` and the
+ * client-hosted row marker cover every tab kind: a press
+ * on a tab starts a dnd-kit reorder, so it must never also move the panel. */
+const FLOATING_TERMINAL_NO_DRAG_SELECTOR =
+ 'button,input,textarea,select,[role="menuitem"],[data-tab-id],[data-client-hosted-browser-row-id],[data-floating-terminal-no-drag]'
+
+export function isFloatingTerminalDragTarget(target: EventTarget): boolean {
+ if (typeof Element === 'undefined' || !(target instanceof Element)) {
+ return true
+ }
+
+ return target.closest(FLOATING_TERMINAL_NO_DRAG_SELECTOR) === null
+}
diff --git a/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts b/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts
new file mode 100644
index 00000000000..f52e08fdfbc
--- /dev/null
+++ b/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts
@@ -0,0 +1,204 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { act, createElement } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
+import type { Tab, TabGroup } from '../../../../shared/tab-types'
+import { useAppStore } from '../../store'
+import { useTabDragSplit, type TabDragItemData } from '../tab-group/useTabDragSplit'
+import { isFloatingTerminalDragTarget } from './floating-terminal-titlebar-drag-target'
+
+vi.mock('../browser-pane/host-guest/webview-registry', () => ({
+ acquireWebviewsDragPassthrough: vi.fn(() => vi.fn())
+}))
+
+vi.mock('../../runtime/web-runtime-session', () => ({
+ isWebRuntimeSessionActive: vi.fn(() => false),
+ moveWebRuntimeSessionTab: vi.fn()
+}))
+
+const GROUP_ID = 'floating-group'
+const mounted: { container: HTMLDivElement; root: Root }[] = []
+
+function makeTab(id: string, sortOrder: number): Tab {
+ return {
+ id,
+ groupId: GROUP_ID,
+ worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
+ contentType: 'terminal',
+ entityId: `term-${id}`,
+ label: id,
+ customLabel: null,
+ color: null,
+ sortOrder,
+ createdAt: sortOrder
+ }
+}
+
+function makeDragData(unifiedTabId: string): TabDragItemData {
+ return {
+ kind: 'tab',
+ worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
+ groupId: GROUP_ID,
+ unifiedTabId,
+ visibleTabId: `term-${unifiedTabId}`,
+ tabType: 'terminal',
+ label: unifiedTabId
+ }
+}
+
+function makeDropEvent(
+ activeData: TabDragItemData,
+ overData: TabDragItemData | null,
+ pointerX: number
+) {
+ return {
+ active: { data: { current: activeData }, rect: { current: { initial: null } } },
+ over: overData
+ ? {
+ id: overData.visibleTabId,
+ data: { current: overData },
+ // Tabs sit in a 100px-wide strip slot starting at x=200; midpoint is 250.
+ rect: { left: 200, top: 0, width: 100, height: 32 }
+ }
+ : null,
+ delta: { x: 0, y: 0 },
+ activatorEvent: { clientX: pointerX, clientY: 16 }
+ }
+}
+
+function renderFloatingDragHook(): ReturnType {
+ let result: ReturnType | null = null
+ function Probe(): null {
+ result = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled: true })
+ return null
+ }
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ act(() => root.render(createElement(Probe)))
+ mounted.push({ container, root })
+ if (!result) {
+ throw new Error('useTabDragSplit did not render')
+ }
+ return result
+}
+
+function dropTab(from: string, over: string, pointerX: number): void {
+ const drag = renderFloatingDragHook()
+ const activeData = makeDragData(from)
+ act(() => {
+ drag.onDragStart(
+ makeDropEvent(activeData, null, pointerX) as unknown as Parameters[0]
+ )
+ })
+ act(() => {
+ drag.onDragEnd(
+ makeDropEvent(activeData, makeDragData(over), pointerX) as unknown as Parameters<
+ typeof drag.onDragEnd
+ >[0]
+ )
+ })
+}
+
+function floatingTabOrder(): string[] {
+ const groups: TabGroup[] =
+ useAppStore.getState().groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
+ return groups.find((group) => group.id === GROUP_ID)?.tabOrder ?? []
+}
+
+beforeEach(() => {
+ useAppStore.setState({
+ // The floating workspace is never the active worktree; its tabs still reorder.
+ activeWorktreeId: 'wt-main',
+ activeGroupIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: GROUP_ID },
+ groupsByWorktree: {
+ [FLOATING_TERMINAL_WORKTREE_ID]: [
+ {
+ id: GROUP_ID,
+ worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
+ activeTabId: 'tab-a',
+ tabOrder: ['tab-a', 'tab-b', 'tab-c']
+ }
+ ]
+ },
+ unifiedTabsByWorktree: {
+ [FLOATING_TERMINAL_WORKTREE_ID]: [
+ makeTab('tab-a', 0),
+ makeTab('tab-b', 1),
+ makeTab('tab-c', 2)
+ ]
+ },
+ layoutByWorktree: {}
+ })
+})
+
+afterEach(() => {
+ for (const { container, root } of mounted.splice(0)) {
+ act(() => root.unmount())
+ container.remove()
+ }
+ document.body.replaceChildren()
+ vi.clearAllMocks()
+})
+
+describe('floating workspace tab reorder', () => {
+ it('moves a tab after the hovered tab when released on its right half', () => {
+ dropTab('tab-a', 'tab-c', 290)
+ expect(floatingTabOrder()).toEqual(['tab-b', 'tab-c', 'tab-a'])
+ })
+
+ it('moves a tab before the hovered tab when released on its left half', () => {
+ dropTab('tab-c', 'tab-a', 210)
+ expect(floatingTabOrder()).toEqual(['tab-c', 'tab-a', 'tab-b'])
+ })
+
+ it('leaves the order alone when a tab is dropped on itself', () => {
+ dropTab('tab-b', 'tab-b', 290)
+ expect(floatingTabOrder()).toEqual(['tab-a', 'tab-b', 'tab-c'])
+ })
+
+ it('republishes sortOrder so the panel renders the committed order', () => {
+ dropTab('tab-a', 'tab-c', 290)
+ const tabs = useAppStore.getState().unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
+ expect(tabs.map((tab) => [tab.id, tab.sortOrder])).toEqual([
+ ['tab-a', 2],
+ ['tab-b', 0],
+ ['tab-c', 1]
+ ])
+ })
+})
+
+describe('floating panel titlebar drag target', () => {
+ it.each(['sortable-tab', 'browser-tab', 'editor-tab', 'client-hosted-browser-row-id'])(
+ 'does not move the panel when the press lands on a %s',
+ (kind) => {
+ const tab = document.createElement('div')
+ tab.dataset.tabId = kind
+ const label = document.createElement('span')
+ tab.appendChild(label)
+ document.body.appendChild(tab)
+
+ expect(isFloatingTerminalDragTarget(tab)).toBe(false)
+ expect(isFloatingTerminalDragTarget(label)).toBe(false)
+ }
+ )
+
+ it('does not move the panel when a tab SVG icon receives the press', () => {
+ const tab = document.createElement('div')
+ tab.dataset.tabId = 'terminal-tab'
+ const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ tab.appendChild(icon)
+ document.body.appendChild(tab)
+
+ expect(isFloatingTerminalDragTarget(icon)).toBe(false)
+ })
+
+ it('still moves the panel from bare titlebar chrome', () => {
+ const titlebar = document.createElement('div')
+ document.body.appendChild(titlebar)
+ expect(isFloatingTerminalDragTarget(titlebar)).toBe(true)
+ })
+})
From f400f8fd5f35f0f27ad4697af8789f99fd9a4fa4 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 13:44:03 -0700
Subject: [PATCH 2/2] fix(macos): opt out of press-and-hold so held keys repeat
(#14746) (#15589)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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.
* docs(macos): record the revert hazard and CI's macOS test gap
Two things a reader of this module cannot otherwise know.
A revert leaves the key written in every user's domain forever. AppKit reads
the plist, not this file, so removing the code alone keeps press-and-hold
disabled for everyone who ran an affected build. The sibling period-substitution
module carries the same warning because that fix was already lost once this way.
And the real-binary test file that pins the defaults(1) exit-code semantics this
design rests on never runs in CI: the e2e workflow and both unit-test jobs are
ubuntu and windows, and the only macOS runners in the repo are build and
packaging jobs that run no tests. Those six tests plus the real-bundle e2e case
pass on a developer Mac and execute zero times in a green PR, so the comment
should not imply enforcement that is not there.
Refs #14746
* feat(macos): let users turn the accent menu back on (#14746)
Orca disables press-and-hold for its own preferences domain so held keys
repeat. That is the right default, but the way back was a `defaults write`
buried in a source comment: nothing in docs/ or the README mentioned it, and
the preference is per-application, so it silently takes the accent picker
away from the Markdown editor and every other text field too.
Terminal -> Advanced now carries a "Character Accent Menu" switch, macOS and
desktop only. A web client cannot write a macOS preference for the machine the
user is looking at, so the control and its search-index entry are both gated on
that, not on the client's platform alone.
Precedence, which is the part that is easy to get wrong: the setting is
`undefined` until the user touches it, which is what keeps a hand-run `defaults
write` in charge for everyone who never opens the toggle. Once used, Orca owns
the key and writes exactly what the switch asks for -- `ApplePressAndHoldEnabled`
*is* the accent-menu switch, so it maps straight through with no inversion. The
choice is compared against `appliedSetting` in the existing decision record
rather than against the domain, so a `defaults write` made *after* using the
toggle is still the newer choice and survives the next launch. Re-asserting the
value every launch would have reintroduced the clobbering the record exists to
prevent.
The write lands for the next launch, since AppKit reads the preference as the
process starts, so the toggle shows the same restart banner the window-blur
setting uses. That banner is now a shared component, keeping its original
translation keys.
docs/reference/macos-press-and-hold.md records the precedence rules, the
`defaults read` rationale, the revert hazard, and the fact that none of this
executes in CI: every macOS job builds or packages and runs no tests, so the
real-binary and e2e coverage here passes only on a developer Mac.
* docs(macos): stop asserting when AppKit re-reads the press-and-hold key
Five places stated "AppKit reads the preference as the process starts" as
fact. That is the reason given for requiring a relaunch, and it is not
something this change ever measured.
Evidence points the other way: terminal emulators that register this key
after their process has started get key repeat in that same launch, which a
read-once-at-startup model cannot explain.
The relaunch requirement itself still looks right, but for a different and
verifiable reason: the write goes out through a separate `defaults` process,
so this app's own cached copy need not observe it. That is what the comments
now say, with the AppKit question left open rather than answered.
Refs #14746
* docs(macos): correct the startup comment's launch-timing claim
The comment said this call site is "the last point that can still matter for
this launch", which contradicts the rest of the module: the write is assumed
to land for the next launch because it goes out through a separate `defaults`
process. Reported on the PR by @innocarpe, who also supplied the replacement
wording.
Co-authored-by: innocarpe
* refactor(macos): probe press-and-hold through the shared spawn chokepoint
`src/shared/child-process/child-process-import-boundary.test.ts` forbids a
direct `node:child_process` import outside its allowlist, and the allowlist only
shrinks — so this module moves to `runProcessSync`, which exists for callers
that genuinely cannot await. This one runs before `app.whenReady()`.
`runProcessSync` returns a non-zero exit instead of throwing it, so the
three-way read decision is re-expressed against `ProcessResult`: exit 0 is an
explicit value, exit 1 is a missing key, and a timeout, a signal kill, any other
exit, or a child that never started all stay 'unknown'. The throw path is now
inside `interpretDefaultsRead` so a spawn failure is reachable from a test
rather than hidden in an untested catch, and the write checks the exit code —
a refused `defaults write` no longer looks like success.
Both boundary-test failures were the same import: with it gone the offender
count returns to 155, so no ratchet baseline is bumped.
* Revert "feat(macos): let users turn the accent menu back on (#14746)"
This reverts commit cc5669f3068fd736a1fcf6017f1ea36b18f9ee04.
---------
Co-authored-by: innocarpe
---
.gitignore | 1 +
docs/reference/macos-press-and-hold.md | 58 ++++
src/main/index.ts | 4 +
...s-and-hold-default.defaults-domain.test.ts | 173 +++++++++++
src/main/macos-press-and-hold-default.test.ts | 286 ++++++++++++++++++
src/main/macos-press-and-hold-default.ts | 257 ++++++++++++++++
.../e2e/macos-press-and-hold-startup.spec.ts | 96 ++++++
7 files changed, 875 insertions(+)
create mode 100644 docs/reference/macos-press-and-hold.md
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/.gitignore b/.gitignore
index a08e1058eeb..176206bacfb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -103,6 +103,7 @@ docs/**
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
+!docs/reference/macos-press-and-hold.md
!docs/reference/orcad-operations.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/windows-process-enumeration.md
diff --git a/docs/reference/macos-press-and-hold.md b/docs/reference/macos-press-and-hold.md
new file mode 100644
index 00000000000..946a1ff7125
--- /dev/null
+++ b/docs/reference/macos-press-and-hold.md
@@ -0,0 +1,58 @@
+# macOS press-and-hold and key repeat
+
+macOS opens the accent picker when a key is held unless an application opts out in its preferences
+domain. That prevents held keys from repeating in terminal applications such as vim. On the first
+eligible launch, Orca writes:
+
+```sh
+defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false
+```
+
+The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and
+non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect
+on the next launch.
+
+## Precedence and decision record
+
+Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a
+user choice and preserved. Only an unset key receives the `false` default.
+
+The decision is stored once in
+`/macos-press-and-hold-default.json`. An `applied` or
+`kept-user-preference` decision prevents future launches from touching the domain again.
+Probe and write failures remain retryable so a transient failure does not permanently disable the
+fix.
+
+`defaults read ` is used instead of
+`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an
+explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures,
+timeouts, and other exit statuses leave the preference alone.
+
+## Restoring the accent picker
+
+Set the preference explicitly, then restart Orca:
+
+```sh
+defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true
+```
+
+After Orca has recorded its one-time decision, deleting the key also restores the macOS default
+without Orca recreating it:
+
+```sh
+defaults delete com.stablyai.orca ApplePressAndHoldEnabled
+```
+
+Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that
+domain instead when applicable.
+
+## Reverting
+
+Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert
+must also arrange to delete the key for users who ran an affected build.
+
+## Test coverage
+
+Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership,
+record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins
+the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS.
diff --git a/src/main/index.ts b/src/main/index.ts
index a142fc6cf7d..c21adc53642 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -13,6 +13,7 @@ import {
type Tray,
session
} 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 {
@@ -938,6 +939,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: initDataPath above gives the canonical userData path for the record file; the write
+ // itself lands for the next launch (see macos-press-and-hold-default.ts).
+ 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..18dab052450
--- /dev/null
+++ b/src/main/macos-press-and-hold-default.defaults-domain.test.ts
@@ -0,0 +1,173 @@
+import { execFileSync } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
+import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
+import { homedir, tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { runProcessSync } from '../shared/child-process/run-process'
+import {
+ PRESS_AND_HOLD_KEY,
+ ensureMacPressAndHoldDefault,
+ interpretDefaultsRead,
+ 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[] = []
+
+/** Pinned against the real binary below, then reused by the platform-agnostic unit tests. */
+const DEFAULTS_MISSING_EXIT_CODE = 1
+
+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"', () => {
+ // Real runs, not hand-built results: a binary that is not there must come back 'unknown'
+ // through the same path production takes, not be mistaken for "the key does not exist".
+ const spawnFailure = (): ReturnType =>
+ runProcessSync({
+ program: '/usr/bin/defaults-does-not-exist',
+ args: ['read'],
+ stdio: ['ignore', 'ignore', 'ignore']
+ })
+ expect(spawnFailure).toThrow()
+ expect(interpretDefaultsRead(spawnFailure)).toBe('unknown')
+
+ const missingKey = runProcessSync({
+ program: '/usr/bin/defaults',
+ args: ['read', throwawayDomain(), PRESS_AND_HOLD_KEY],
+ stdio: ['ignore', 'ignore', 'ignore']
+ })
+ // The one exit code the whole design reads as unset; every other outcome is 'unknown'.
+ expect(missingKey.code).toBe(DEFAULTS_MISSING_EXIT_CODE)
+ expect(missingKey.timedOut).toBe(false)
+ expect(interpretDefaultsRead(() => missingKey)).toBe('unset')
+ })
+
+ // Skipped as root, where the read-only directory below would still be writable.
+ it.skipIf(process.getuid?.() === 0)('reports a write the binary refused', () => {
+ // Why a real refusal: `defaults write` exits non-zero instead of throwing, so a caller that
+ // only caught exceptions would record 'applied' for a value that never reached the plist.
+ const readOnly = join(mkdtempSync(join(tmpdir(), 'orca-press-hold-ro-')), 'locked')
+ mkdirSync(readOnly)
+ chmodSync(readOnly, 0o500)
+ try {
+ expect(writeDomainPressAndHoldPreference(join(readOnly, 'domain'), false)).toBe(false)
+ } finally {
+ chmodSync(readOnly, 0o700)
+ rmSync(readOnly, { recursive: true, force: true })
+ }
+ })
+
+ 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..c30aed82d10
--- /dev/null
+++ b/src/main/macos-press-and-hold-default.test.ts
@@ -0,0 +1,286 @@
+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 type { ProcessResult } from '../shared/child-process/run-process'
+import {
+ ensureMacPressAndHoldDefault,
+ interpretDefaultsRead,
+ 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([])
+ })
+})
+
+/**
+ * Why here and not only in the .defaults-domain file: that file is macOS-only and CI has no macOS
+ * runner, so this three-way rule would otherwise be enforced nowhere in CI. These cases need no
+ * `defaults` binary — the sibling file pins that the real binary produces them.
+ */
+describe('interpretDefaultsRead', () => {
+ function probe(overrides: Partial): () => ProcessResult {
+ return () => ({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...overrides })
+ }
+
+ it('reads a clean exit as an explicit value', () => {
+ expect(interpretDefaultsRead(probe({ code: 0 }))).toBe('set')
+ })
+
+ it('reads only "does not exist" as unset', () => {
+ expect(interpretDefaultsRead(probe({ code: 1 }))).toBe('unset')
+ })
+
+ describe('refuses to call a failed probe unset', () => {
+ // Each of these would otherwise overwrite a value the user deliberately set.
+ const failures: [string, () => ProcessResult][] = [
+ ['killed before it exited', probe({ code: null, signal: 'SIGTERM' })],
+ ['timed out', probe({ code: null, timedOut: true })],
+ [
+ 'timed out after somehow reporting the missing-key code',
+ probe({ code: 1, timedOut: true })
+ ],
+ ['exited with some other error', probe({ code: 2 })],
+ ['exited with a negative status', probe({ code: -1 })],
+ [
+ 'never started at all',
+ () => {
+ throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })
+ }
+ ]
+ ]
+ for (const [name, failing] of failures) {
+ it(name, () => {
+ expect(interpretDefaultsRead(failing)).toBe('unknown')
+ })
+ }
+ })
+})
+
+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..41b7819f2b2
--- /dev/null
+++ b/src/main/macos-press-and-hold-default.ts
@@ -0,0 +1,257 @@
+import { mkdirSync, readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { runProcessSync, type ProcessResult } from '../shared/child-process/run-process'
+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.
+ *
+ * A fresh write is assumed to land for the *next* launch, not the current one: it goes out through
+ * a separate `defaults` process, so this app's own cached copy may not observe it. Whether AppKit
+ * re-reads sooner is untested, so the conservative assumption is the one encoded here.
+ *
+ * If this is ever reverted, delete the key as well — AppKit reads the plist, not this file, so
+ * removing the code alone leaves press-and-hold disabled forever for everyone who ran an affected
+ * build, with nothing left in the tree to explain it.
+ *
+ * The `defaults`-binary behaviour this relies on is pinned by the sibling test file, which runs
+ * only on a developer Mac: CI has no macOS test runner, so those tests never execute there.
+ */
+
+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'
+/** A hang cutoff, not a latency budget: the probe measures ~6ms, and cutting a slow-but-working
+ * `defaults` short costs the user the feature for that launch. The shared 30s default is what
+ * must not apply — this runs before `ready`, so a 30s block is a hung app. */
+const DEFAULTS_TIMEOUT_MS = 5_000
+/** Why: `defaults` exits 1 for "does not exist"; anything else means 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 killed or timed-out probe never answered, and calling that unset would let a broken
+ * probe overwrite a value the user chose.
+ */
+export function interpretDefaultsRead(probe: () => ProcessResult): 'set' | 'unset' | 'unknown' {
+ let result: ProcessResult
+ try {
+ result = probe()
+ } catch {
+ // `runProcessSync` throws only when the child never started, which answers nothing at all.
+ return 'unknown'
+ }
+ // Why before the code: a timeout kills mid-run, so any exit it leaves behind is not an answer.
+ if (result.timedOut) {
+ return 'unknown'
+ }
+ if (result.code === 0) {
+ return 'set'
+ }
+ return result.code === DEFAULTS_MISSING_STATUS ? 'unset' : 'unknown'
+}
+
+export function readDomainPressAndHoldPreference(domain: string): 'set' | 'unset' | 'unknown' {
+ // Why no captured output: the exit code is the whole answer, and reading only presence is what
+ // makes an explicit `true` and an explicit `false` both count as the user's.
+ return interpretDefaultsRead(() =>
+ runProcessSync({
+ program: DEFAULTS_BINARY,
+ args: ['read', domain, PRESS_AND_HOLD_KEY],
+ timeoutMs: DEFAULTS_TIMEOUT_MS,
+ stdio: ['ignore', 'ignore', 'ignore']
+ })
+ )
+}
+
+export function writeDomainPressAndHoldPreference(domain: string, value: boolean): boolean {
+ try {
+ // Why the code check: a non-zero `defaults write` returns rather than throwing, so treating a
+ // completed run as success would record 'applied' for a write that never landed.
+ return (
+ runProcessSync({
+ program: DEFAULTS_BINARY,
+ args: ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value ? 'true' : 'false'],
+ timeoutMs: DEFAULTS_TIMEOUT_MS,
+ stdio: ['ignore', 'ignore', 'ignore']
+ }).code === 0
+ )
+ } 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..5eb0fba388d
--- /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 this process does not re-read after writing, 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()
+ })
+})