mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-3919-mobile-picker-lag
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
`<userData>/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 <domain> <key>` 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.
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<typeof runProcessSync> =>
|
||||
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')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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<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([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 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>): () => 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(
|
||||
'<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,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 <domain> <key>` 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<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 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<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
|
||||
}
|
||||
+26
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-1">
|
||||
<FloatingWorkspaceTabDragContext enabled={open}>
|
||||
<TabBar
|
||||
tabs={terminalItems}
|
||||
activeTabId={activeTerminalId}
|
||||
@@ -1874,7 +1870,7 @@ export function FloatingTerminalPanel({
|
||||
tabBarOrder={tabBarOrder}
|
||||
tabStripChrome="floating-panel"
|
||||
/>
|
||||
</div>
|
||||
</FloatingWorkspaceTabDragContext>
|
||||
<FloatingTerminalWindowControls
|
||||
maximized={maximized}
|
||||
onToggleMaximized={toggleMaximized}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cloneElement } from 'react'
|
||||
import { DndContext, DragOverlay } from '@dnd-kit/core'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import TabDragPreview from '../tab-bar/TabDragPreview'
|
||||
import { TabDragProvider } from '../tab-group/tab-drag-context'
|
||||
import { useTabDragSplit, type HoveredTabInsertion } from '../tab-group/useTabDragSplit'
|
||||
|
||||
/** dnd-kit host for the floating workspace tab strip, so its tabs reorder with
|
||||
* the same gesture and insertion bar the main workspace tab bar uses. The panel
|
||||
* renders one group and no split layout, so a drop only ever resolves to a
|
||||
* same-group reorder — the pane-column split branches find no panel geometry. */
|
||||
export function FloatingWorkspaceTabDragContext({
|
||||
enabled,
|
||||
children
|
||||
}: {
|
||||
/** False while the panel is hidden: a closed panel must not register sensors that compete with the workspace DndContext. */
|
||||
enabled: boolean
|
||||
children: React.ReactElement<{ hoveredTabInsertion?: HoveredTabInsertion | null }>
|
||||
}): React.JSX.Element {
|
||||
const dragSplit = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled })
|
||||
|
||||
return (
|
||||
<TabDragProvider
|
||||
isTabDragActive={dragSplit.activeDrag !== null}
|
||||
isTabDragActiveRef={dragSplit.isTabDragActiveRef}
|
||||
>
|
||||
<DndContext
|
||||
sensors={dragSplit.sensors}
|
||||
collisionDetection={dragSplit.collisionDetection}
|
||||
onDragStart={dragSplit.onDragStart}
|
||||
onDragMove={dragSplit.onDragMove}
|
||||
onDragOver={dragSplit.onDragOver}
|
||||
onDragEnd={dragSplit.onDragEnd}
|
||||
onDragCancel={dragSplit.onDragCancel}
|
||||
// Why: same feedback loop the workspace strip hits — autoscroll shifts tabs under a still cursor and `over` re-resolves.
|
||||
autoScroll={false}
|
||||
>
|
||||
<div ref={dragSplit.setDragRootNode} className="flex h-full min-w-0 flex-1">
|
||||
{/* 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 })}
|
||||
</div>
|
||||
{/* Why: the source tab stays anchored in the overflow-hidden strip; this ghost is what follows the cursor. */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{dragSplit.activeDrag ? <TabDragPreview drag={dragSplit.activeDrag} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</TabDragProvider>
|
||||
)
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
@@ -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<typeof useTabDragSplit> {
|
||||
let result: ReturnType<typeof useTabDragSplit> | 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<typeof drag.onDragStart>[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)
|
||||
})
|
||||
})
|
||||
@@ -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<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