diff --git a/src/main/index.ts b/src/main/index.ts index 44d8e4d41d3..67d0dfe7ed6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() } }) diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index fa630248d60..e12a438d321 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -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']] diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts index 6f25abfc162..e6d4564f9dd 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -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 } diff --git a/src/main/window/foreground-activation-policy.test.ts b/src/main/window/foreground-activation-policy.test.ts index 3b33c9be881..91c71bbd28f 100644 --- a/src/main/window/foreground-activation-policy.test.ts +++ b/src/main/window/foreground-activation-policy.test.ts @@ -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() diff --git a/src/main/window/foreground-activation-policy.ts b/src/main/window/foreground-activation-policy.ts index d010f11c4ec..864916d9f6a 100644 --- a/src/main/window/foreground-activation-policy.ts +++ b/src/main/window/foreground-activation-policy.ts @@ -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> +/** 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') ) } diff --git a/src/main/window/macos-desktop-handoff.test.ts b/src/main/window/macos-desktop-handoff.test.ts new file mode 100644 index 00000000000..4e57fb1613c --- /dev/null +++ b/src/main/window/macos-desktop-handoff.test.ts @@ -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 } { + 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 => 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([]) + }) +}) diff --git a/src/main/window/macos-desktop-handoff.ts b/src/main/window/macos-desktop-handoff.ts new file mode 100644 index 00000000000..942a2c29578 --- /dev/null +++ b/src/main/window/macos-desktop-handoff.ts @@ -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> + } = {} +): 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>): 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() + ] +}