mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(orcad): hand macOS opens from the browser sidecar to the desktop app (#29258)
* fix(orcad): hand macOS opens from the browser sidecar to the desktop app On macOS 27, LaunchServices registers orcad's Electron browser sidecar even when orcad was started over SSH, and sends the user's opens to the first registered instance of the bundle regardless of its activation policy. Opening Orca on such a host showed onboarding on the sidecar's throwaway profile instead of the user's own. orcad now marks the sidecar with ORCA_ORCAD_SIDECAR=1. The app treats the marker as a background launch and hands app opens, skill share links and opened documents to a new desktop instance with `open -n` and an empty environment, one launch per batch. An older installed app ignores the marker and keeps its current behavior. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(orcad): an automated sidecar never launches the real desktop app --------- Co-authored-by: jbamlee <dev.jbam@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
jbamlee
parent
715d979f42
commit
93095c2f67
+13
-3
@@ -1,7 +1,8 @@
|
||||
import { app, clipboard, dialog, type BrowserWindow } from 'electron'
|
||||
import { parseSkillShareId } from '../shared/skill-share-link'
|
||||
import { createMacAppActivationHandler } from './window/macos-app-activation'
|
||||
import { isBackgroundLaunch } from './window/foreground-activation-policy'
|
||||
import { isBackgroundLaunch, isOrcadSidecarLaunch } from './window/foreground-activation-policy'
|
||||
import { createDesktopHandoff, desktopHandoffArguments } from './window/macos-desktop-handoff'
|
||||
import {
|
||||
focusExistingWindow as focusExistingWindowAction,
|
||||
setMainWindowOpener
|
||||
@@ -38,6 +39,10 @@ function focusExistingWindow(): void {
|
||||
focusExistingWindowAction()
|
||||
}
|
||||
|
||||
const requestDesktopHandoff = createDesktopHandoff({
|
||||
drainArguments: () => desktopHandoffArguments(state)
|
||||
})
|
||||
|
||||
function requestDesktopActivation(argv: readonly string[] = []): void {
|
||||
state.skillShareDeepLinks.capture(argv, (shareId) => {
|
||||
state.mainWindow?.webContents.send('ui:openSkillShare', shareId)
|
||||
@@ -47,6 +52,11 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
|
||||
if (!shouldActivateDesktopForSecondInstance(argv)) {
|
||||
return
|
||||
}
|
||||
// Why: the sidecar's throwaway profile must never become the user's window.
|
||||
if (isOrcadSidecarLaunch()) {
|
||||
requestDesktopHandoff()
|
||||
return
|
||||
}
|
||||
state.desktopActivationGate?.requestActivation()
|
||||
}
|
||||
|
||||
@@ -111,8 +121,8 @@ if (preflightReady) {
|
||||
}
|
||||
event.preventDefault()
|
||||
// Why gated on isReady: pre-ready the cold-start window is already on its way, and
|
||||
// activating the gate here would try to open one before Electron can.
|
||||
if (app.isReady()) {
|
||||
// activating the gate here would try to open one before Electron can. The sidecar has none.
|
||||
if (app.isReady() || isOrcadSidecarLaunch()) {
|
||||
requestDesktopActivation()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -227,6 +227,12 @@ describe('ElectronServeBrowserProcess start-up', () => {
|
||||
expect(processHandle.isAvailable()).toBe(true)
|
||||
})
|
||||
|
||||
it('marks the launch as the orcad sidecar so the installed app hands user opens on', async () => {
|
||||
await startProvider()
|
||||
|
||||
expect(spawnSpec().env?.ORCA_ORCAD_SIDECAR).toBe('1')
|
||||
})
|
||||
|
||||
it('keeps polling until the sidecar advertises browser.headless.v1', async () => {
|
||||
await setControl({
|
||||
capabilities: [['runtime.v1'], ['runtime.v1'], ['runtime.v1', 'browser.headless.v1']]
|
||||
|
||||
@@ -77,6 +77,8 @@ function electronServeEnvironment(userDataPath: string): NodeJS.ProcessEnv {
|
||||
if (process.env.ORCA_E2E_USER_DATA_DIR || process.env.ORCA_E2E_HOME_DIR) {
|
||||
environment.ORCA_E2E_USER_DATA_DIR = userDataPath
|
||||
}
|
||||
// Why not ORCA_BACKGROUND_LAUNCH: an older installed app would hide without handing opens to the desktop app.
|
||||
environment.ORCA_ORCAD_SIDECAR = '1'
|
||||
return environment
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ vi.mock('electron', () => ({ app: { dock: { hide: vi.fn() }, setActivationPolicy
|
||||
const {
|
||||
applyBackgroundActivationPolicy,
|
||||
isBackgroundLaunch,
|
||||
isOrcadSidecarLaunch,
|
||||
isWindowlessLaunch,
|
||||
showWindowWithoutStealingFocus
|
||||
} = await import('./foreground-activation-policy')
|
||||
@@ -36,6 +37,13 @@ describe('isBackgroundLaunch', () => {
|
||||
expect(isBackgroundLaunch({ ORCA_BACKGROUND_LAUNCH: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(true)
|
||||
})
|
||||
|
||||
it('treats the orcad browser sidecar as an explicit background launch', () => {
|
||||
expect(isOrcadSidecarLaunch({ ORCA_ORCAD_SIDECAR: '1' })).toBe(true)
|
||||
expect(isOrcadSidecarLaunch({})).toBe(false)
|
||||
expect(isBackgroundLaunch({ ORCA_ORCAD_SIDECAR: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(true)
|
||||
expect(isWindowlessLaunch({ ORCA_ORCAD_SIDECAR: '1', ORCA_E2E_HEADFUL: '1' })).toBe(true)
|
||||
})
|
||||
|
||||
it('lets native-focus specs opt back into the foreground', () => {
|
||||
expect(isBackgroundLaunch({ ORCA_E2E_HEADFUL: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(false)
|
||||
expect(isWindowlessLaunch({ ORCA_E2E_HEADLESS: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(false)
|
||||
@@ -101,7 +109,7 @@ describe('applyBackgroundActivationPolicy', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it.each(['ORCA_E2E_HEADLESS', 'ORCA_BACKGROUND_LAUNCH'])(
|
||||
it.each(['ORCA_E2E_HEADLESS', 'ORCA_BACKGROUND_LAUNCH', 'ORCA_ORCAD_SIDECAR'])(
|
||||
'drops the macOS Dock tile and menu bar for %s',
|
||||
(flag) => {
|
||||
const app = makeApp()
|
||||
|
||||
@@ -9,6 +9,7 @@ import { app as electronApp, type BrowserWindow } from 'electron'
|
||||
* can use ORCA_E2E_FOREGROUND=1 only without an explicit background request.
|
||||
* The hosted-Xvfb terminal benchmark explicitly presents after startup; see tests/AGENTS.md.
|
||||
* This policy still suppresses its automatic reveals and foreground activation.
|
||||
* orcad's browser sidecar (ORCA_ORCAD_SIDECAR=1) is held to the same explicit request.
|
||||
*/
|
||||
|
||||
type ActivationPolicyApp = {
|
||||
@@ -16,12 +17,21 @@ type ActivationPolicyApp = {
|
||||
setActivationPolicy: (policy: 'accessory' | 'prohibited' | 'regular') => void
|
||||
}
|
||||
|
||||
/** Reads ORCA_BACKGROUND_LAUNCH, ORCA_E2E_FOREGROUND, ORCA_E2E_HEADLESS, ORCA_E2E_HEADFUL. */
|
||||
/** Reads ORCA_BACKGROUND_LAUNCH, ORCA_ORCAD_SIDECAR, ORCA_E2E_FOREGROUND, ORCA_E2E_HEADLESS, ORCA_E2E_HEADFUL. */
|
||||
type PolicyEnv = Readonly<Record<string, string | undefined>>
|
||||
|
||||
/** orcad's headless browser provider: a serve process on a throwaway profile that no user drives. */
|
||||
export function isOrcadSidecarLaunch(env: PolicyEnv = process.env): boolean {
|
||||
return env.ORCA_ORCAD_SIDECAR === '1'
|
||||
}
|
||||
|
||||
function isExplicitBackgroundLaunch(env: PolicyEnv): boolean {
|
||||
return env.ORCA_BACKGROUND_LAUNCH === '1' || isOrcadSidecarLaunch(env)
|
||||
}
|
||||
|
||||
/** True when this process must not steal focus, raise windows, or activate the app. */
|
||||
export function isBackgroundLaunch(env: PolicyEnv = process.env): boolean {
|
||||
if (env.ORCA_BACKGROUND_LAUNCH === '1') {
|
||||
if (isExplicitBackgroundLaunch(env)) {
|
||||
return true
|
||||
}
|
||||
if (env.ORCA_E2E_FOREGROUND === '1') {
|
||||
@@ -33,7 +43,7 @@ export function isBackgroundLaunch(env: PolicyEnv = process.env): boolean {
|
||||
/** Suppresses automatic presentation on launch; this is not a query of current window visibility. */
|
||||
export function isWindowlessLaunch(env: PolicyEnv = process.env): boolean {
|
||||
return (
|
||||
env.ORCA_BACKGROUND_LAUNCH === '1' ||
|
||||
isExplicitBackgroundLaunch(env) ||
|
||||
(isBackgroundLaunch(env) && env.ORCA_E2E_HEADLESS === '1' && env.ORCA_E2E_HEADFUL !== '1')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { skillShareIdFromArguments } from '../../shared/skill-share-link'
|
||||
|
||||
const { spawnProcessMock } = vi.hoisted(() => ({ spawnProcessMock: vi.fn() }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/Applications/Orca.app/Contents/MacOS/Orca',
|
||||
dock: { hide: vi.fn() },
|
||||
setActivationPolicy: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('@orca/process-host', () => ({ spawnProcess: spawnProcessMock }))
|
||||
|
||||
const { createDesktopHandoff, desktopHandoffArguments, handOffToDesktopApp } =
|
||||
await import('./macos-desktop-handoff')
|
||||
|
||||
function makeChild(): EventEmitter & { unref: ReturnType<typeof vi.fn> } {
|
||||
return Object.assign(new EventEmitter(), { unref: vi.fn() })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
spawnProcessMock.mockReset()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('handOffToDesktopApp', () => {
|
||||
it('opens a new instance of the installed bundle with a Dock launch environment', () => {
|
||||
const child = makeChild()
|
||||
spawnProcessMock.mockReturnValue(child)
|
||||
|
||||
handOffToDesktopApp([], {
|
||||
platform: 'darwin',
|
||||
env: {},
|
||||
reapplyActivationPolicy: vi.fn()
|
||||
})
|
||||
|
||||
expect(spawnProcessMock).toHaveBeenCalledWith({
|
||||
program: '/usr/bin/open',
|
||||
args: ['-n', '/Applications/Orca.app'],
|
||||
env: {},
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
expect(child.unref).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('passes buffered links and documents to the new instance', () => {
|
||||
spawnProcessMock.mockReturnValue(makeChild())
|
||||
|
||||
handOffToDesktopApp(['orca://skills/share/abc', '/Users/me/notes.md'], {
|
||||
platform: 'darwin',
|
||||
env: {},
|
||||
executablePath: '/Users/me/Applications/Orca.app/Contents/MacOS/Orca',
|
||||
reapplyActivationPolicy: vi.fn()
|
||||
})
|
||||
|
||||
expect(spawnProcessMock.mock.calls[0][0].args).toEqual([
|
||||
'-n',
|
||||
'/Users/me/Applications/Orca.app',
|
||||
'--args',
|
||||
'orca://skills/share/abc',
|
||||
'/Users/me/notes.md'
|
||||
])
|
||||
})
|
||||
|
||||
it('drops back out of the Dock after launching the desktop app', () => {
|
||||
spawnProcessMock.mockReturnValue(makeChild())
|
||||
const reapplyActivationPolicy = vi.fn()
|
||||
|
||||
handOffToDesktopApp([], {
|
||||
platform: 'darwin',
|
||||
env: {},
|
||||
reapplyActivationPolicy
|
||||
})
|
||||
|
||||
expect(reapplyActivationPolicy).toHaveBeenCalledOnce()
|
||||
expect(reapplyActivationPolicy.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
spawnProcessMock.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('logs a failed launch instead of taking main down', () => {
|
||||
const child = makeChild()
|
||||
spawnProcessMock.mockReturnValue(child)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
handOffToDesktopApp([], {
|
||||
platform: 'darwin',
|
||||
env: {},
|
||||
reapplyActivationPolicy: vi.fn()
|
||||
})
|
||||
child.emit('error', new Error('spawn /usr/bin/open ENOENT'))
|
||||
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([{ ORCA_BACKGROUND_LAUNCH: '1' }, { ORCA_E2E_USER_DATA_DIR: '/tmp/e2e' }])(
|
||||
'never launches the real desktop app from an automated sidecar (%o)',
|
||||
(env) => {
|
||||
const reapplyActivationPolicy = vi.fn()
|
||||
|
||||
handOffToDesktopApp([], {
|
||||
platform: 'darwin',
|
||||
env,
|
||||
reapplyActivationPolicy
|
||||
})
|
||||
|
||||
expect(spawnProcessMock).not.toHaveBeenCalled()
|
||||
expect(reapplyActivationPolicy).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('does nothing off macOS', () => {
|
||||
const reapplyActivationPolicy = vi.fn()
|
||||
|
||||
handOffToDesktopApp([], { platform: 'linux', reapplyActivationPolicy })
|
||||
|
||||
expect(spawnProcessMock).not.toHaveBeenCalled()
|
||||
expect(reapplyActivationPolicy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDesktopHandoff', () => {
|
||||
const nextTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
it('hands a multi-file open over in one launch carrying every file', async () => {
|
||||
const pending: string[] = []
|
||||
const handOff = vi.fn()
|
||||
const requestHandoff = createDesktopHandoff({
|
||||
drainArguments: () => pending.splice(0),
|
||||
handOff
|
||||
})
|
||||
|
||||
for (const file of ['/Users/me/a.md', '/Users/me/b.md', '/Users/me/c.csv']) {
|
||||
pending.push(file)
|
||||
requestHandoff()
|
||||
}
|
||||
expect(handOff).not.toHaveBeenCalled()
|
||||
await nextTurn()
|
||||
|
||||
expect(handOff).toHaveBeenCalledOnce()
|
||||
expect(handOff).toHaveBeenCalledWith(['/Users/me/a.md', '/Users/me/b.md', '/Users/me/c.csv'])
|
||||
})
|
||||
|
||||
it('launches again for a later open', async () => {
|
||||
const handOff = vi.fn()
|
||||
const requestHandoff = createDesktopHandoff({
|
||||
drainArguments: () => [],
|
||||
handOff
|
||||
})
|
||||
|
||||
requestHandoff()
|
||||
await nextTurn()
|
||||
requestHandoff()
|
||||
await nextTurn()
|
||||
|
||||
expect(handOff).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktopHandoffArguments', () => {
|
||||
it('turns the buffered share id into a link the desktop app reads from argv', () => {
|
||||
const args = desktopHandoffArguments({
|
||||
skillShareDeepLinks: { consume: () => 'abc_123' },
|
||||
osOpenedDocuments: { consume: () => ['/Users/me/notes.md'] }
|
||||
})
|
||||
|
||||
expect(args).toEqual(['orca://skills/share/abc_123', '/Users/me/notes.md'])
|
||||
expect(skillShareIdFromArguments(args)).toBe('abc_123')
|
||||
})
|
||||
|
||||
it('is empty for a plain app open', () => {
|
||||
expect(
|
||||
desktopHandoffArguments({
|
||||
skillShareDeepLinks: { consume: () => null },
|
||||
osOpenedDocuments: { consume: () => [] }
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { app } from 'electron'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { spawnProcess } from '@orca/process-host'
|
||||
import { applyBackgroundActivationPolicy } from './foreground-activation-policy'
|
||||
|
||||
/**
|
||||
* orcad's browser sidecar runs from the installed app bundle. On macOS 27 LaunchServices registers
|
||||
* it even when orcad was started over SSH, and routes the user's opens (Dock, Spotlight, Finder,
|
||||
* `open`, orca:// links) to the first registered instance of the bundle, whatever its activation
|
||||
* policy. The sidecar passes each one to a new desktop instance instead of presenting its profile.
|
||||
*/
|
||||
export function handOffToDesktopApp(
|
||||
args: readonly string[],
|
||||
options: {
|
||||
platform?: NodeJS.Platform
|
||||
executablePath?: string
|
||||
reapplyActivationPolicy?: () => void
|
||||
env?: Readonly<Record<string, string | undefined>>
|
||||
} = {}
|
||||
): void {
|
||||
if ((options.platform ?? process.platform) !== 'darwin') {
|
||||
return
|
||||
}
|
||||
// Why: the empty launch environment would drop these flags and start the user's real profile.
|
||||
if (!isAutomatedLaunch(options.env ?? process.env)) {
|
||||
launchDesktopInstance(args, options.executablePath)
|
||||
}
|
||||
// Why: delivering the open promotes the sidecar into the Dock; drop back out once it is handled.
|
||||
if (options.reapplyActivationPolicy) {
|
||||
options.reapplyActivationPolicy()
|
||||
} else {
|
||||
applyBackgroundActivationPolicy({ warn: console.warn })
|
||||
}
|
||||
}
|
||||
|
||||
function launchDesktopInstance(args: readonly string[], executablePath: string | undefined): void {
|
||||
// `Orca.app/Contents/MacOS/Orca` → `Orca.app`.
|
||||
const bundlePath = resolve(dirname(executablePath ?? app.getPath('exe')), '..', '..')
|
||||
const child = spawnProcess({
|
||||
program: '/usr/bin/open',
|
||||
args: ['-n', bundlePath, ...(args.length > 0 ? ['--args', ...args] : [])],
|
||||
// Why empty: `open` passes its environment to the app; none gives it a Dock launch's environment.
|
||||
env: {},
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
console.warn('[window] Failed to hand activation to the desktop app', error)
|
||||
})
|
||||
child.unref()
|
||||
}
|
||||
|
||||
function isAutomatedLaunch(env: Readonly<Record<string, string | undefined>>): boolean {
|
||||
return (
|
||||
env.ORCA_BACKGROUND_LAUNCH === '1' ||
|
||||
Object.keys(env).some((key) => key.startsWith('ORCA_E2E_') && Boolean(env[key]))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands each batch of opens over in one launch: AppKit delivers a multi-file open as one
|
||||
* `open-file` per file, and a launch per file would start that many desktop processes.
|
||||
*/
|
||||
export function createDesktopHandoff(options: {
|
||||
drainArguments: () => string[]
|
||||
handOff?: (args: readonly string[]) => void
|
||||
}): () => void {
|
||||
const handOff = options.handOff ?? handOffToDesktopApp
|
||||
let scheduled = false
|
||||
return () => {
|
||||
if (scheduled) {
|
||||
return
|
||||
}
|
||||
scheduled = true
|
||||
setImmediate(() => {
|
||||
scheduled = false
|
||||
handOff(options.drainArguments())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Drains what the sidecar buffered from an open into the desktop app's launch arguments. */
|
||||
export function desktopHandoffArguments(pending: {
|
||||
skillShareDeepLinks: { consume: () => string | null }
|
||||
osOpenedDocuments: { consume: () => string[] }
|
||||
}): string[] {
|
||||
const shareId = pending.skillShareDeepLinks.consume()
|
||||
return [
|
||||
...(shareId ? [`orca://skills/share/${shareId}`] : []),
|
||||
...pending.osOpenedDocuments.consume()
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user