diff --git a/docs/site/content/docs/remote-servers.mdx b/docs/site/content/docs/remote-servers.mdx index 7e7021ed967..d9be9701a25 100644 --- a/docs/site/content/docs/remote-servers.mdx +++ b/docs/site/content/docs/remote-servers.mdx @@ -151,6 +151,7 @@ orca-ide serve --pairing-address 100.64.1.20 The command: - starts the Orca runtime without opening the desktop window; +- on macOS, stays out of the Dock and the Cmd-Tab switcher until you open a desktop window from it; - runs in the foreground until you press `Ctrl-C`; - prints the bound endpoint and a runtime pairing URL; - uses `--pairing-address` only for the address clients should dial. @@ -186,6 +187,17 @@ orca-ide serve --pairing-address 100.64.1.20 - Orca reads them only when its terminal host process starts. That process keeps running when `orca serve` restarts, so terminals keep their old limits until it restarts too, for example after the server reboots. - They have no effect on macOS or Windows. +### Use the GPU on a Linux server + +On Linux, `orca serve` turns off hardware acceleration by default, because most headless hosts have no GPU and a failing GPU process can stop the server from starting. Browser panes then render in software. If the host gives the Orca process a GPU (for example a render node such as `/dev/dri/renderD128`), set `ORCA_SERVE_GPU=1` in the environment that starts `orca serve` (or in its service unit) to keep hardware acceleration on: + +```bash +export ORCA_SERVE_GPU=1 +orca-ide serve --pairing-address 100.64.1.20 +``` + +This only stops Orca from turning the GPU off. Chromium may still render in software on Orca's own Xvfb, which has no GPU of its own; open `chrome://gpu` in a browser pane to check, and point `DISPLAY` at a GPU-backed X server if it reports software rendering. If you installed the server with `orca serve service install`, add the variable to the service unit yourself. If the server stops starting or browser panes crash with it set, unset it to go back to software rendering. + ### Mobile from a headless server For the Orca mobile app, request a mobile-scoped QR code and link: diff --git a/src/main/startup/ensure-virtual-display.test.ts b/src/main/startup/ensure-virtual-display.test.ts index afbe10a75ad..b4ed9d23726 100644 --- a/src/main/startup/ensure-virtual-display.test.ts +++ b/src/main/startup/ensure-virtual-display.test.ts @@ -124,6 +124,23 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu') }) + it('keeps hardware acceleration when ORCA_SERVE_GPU=1 opts in', async () => { + setPlatform('linux') + process.env.DISPLAY = ':0' + vi.stubEnv('ORCA_SERVE_GPU', '1') + mockLiveXDisplay() + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + try { + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + } finally { + vi.unstubAllEnvs() + } + expect(appMock.disableHardwareAcceleration).not.toHaveBeenCalled() + expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalledWith('disable-gpu') + }) + it('reports unsupported when Xvfb cannot be launched', async () => { setPlatform('linux') statSyncMock.mockImplementation(() => { diff --git a/src/main/startup/ensure-virtual-display.ts b/src/main/startup/ensure-virtual-display.ts index fd3db4c47d7..2c1177b0893 100644 --- a/src/main/startup/ensure-virtual-display.ts +++ b/src/main/startup/ensure-virtual-display.ts @@ -27,6 +27,10 @@ let xvfbProcess: ChildProcess | null = null function configureHeadlessServeChromiumFlags(): void { // Small sandbox /dev/shm mounts can exhaust and crash Chromium utility services; use disk-backed temp. app.commandLine.appendSwitch('disable-dev-shm-usage') + // Opt-in for hosts that expose a GPU to this process (#22450). + if (process.env.ORCA_SERVE_GPU === '1') { + return + } // External displays may also be Xvfb; GPU forks can trap before serve readiness. app.disableHardwareAcceleration() app.commandLine.appendSwitch('disable-gpu') diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 85720e14dca..379f3e41864 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -60,8 +60,11 @@ import { reportProfileStateSaveDelay } from './profile-state-save-delay' export async function initializeReadyFoundation(): Promise { logStartupMilestone('app-ready') - // Background automation must not claim the macOS Dock/menu bar. - applyBackgroundActivationPolicy({ warn: console.warn }) + // Background automation and a windowless serve must not claim the macOS Dock/menu bar. + applyBackgroundActivationPolicy({ + serveWithoutDesktopWindow: state.isServeMode, + warn: console.warn + }) installElectronProxyRequestGuard(session.defaultSession) app.on('login', (event, webContents, details, authInfo, callback) => { handleElectronProxyLogin( diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index 9a6a1877515..3c9c7df52f1 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -40,6 +40,10 @@ import { requireMainWindowServices } from './main-window-service-readiness' import { recordRendererLaunchFailureProbe } from '../window/renderer-launch-failure-probe' import { isResourceWindowVisible } from '../window/resource-view-admission' +import { + applyBackgroundActivationPolicy, + applyServeDesktopWindowActivationPolicy +} from '../window/foreground-activation-policy' const TRAY_CREATE_FALLBACK_MS = 12_000 const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 @@ -87,6 +91,9 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} noteWindowsInstallDirAclProbePending() } } + if (state.isServeMode) { + applyServeDesktopWindowActivationPolicy({ warn: console.warn }) + } const window = createMainWindow(store, { getIsQuitting: () => state.isQuitting, onQuitAborted: () => { @@ -220,6 +227,9 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} window.on('closed', () => { if (state.mainWindow === window) { state.mainWindow = null + if (state.isServeMode) { + applyBackgroundActivationPolicy({ serveWithoutDesktopWindow: true, warn: console.warn }) + } } clearExpectedRendererReload(rendererWebContentsId) state.automations?.setWebContents(null) diff --git a/src/main/window/foreground-activation-policy.test.ts b/src/main/window/foreground-activation-policy.test.ts index 91c71bbd28f..41b9fbfc612 100644 --- a/src/main/window/foreground-activation-policy.test.ts +++ b/src/main/window/foreground-activation-policy.test.ts @@ -5,6 +5,7 @@ vi.mock('electron', () => ({ app: { dock: { hide: vi.fn() }, setActivationPolicy const { applyBackgroundActivationPolicy, + applyServeDesktopWindowActivationPolicy, isBackgroundLaunch, isOrcadSidecarLaunch, isWindowlessLaunch, @@ -104,7 +105,7 @@ describe('showWindowWithoutStealingFocus', () => { describe('applyBackgroundActivationPolicy', () => { function makeApp() { return { - dock: { hide: vi.fn(() => {}) }, + dock: { hide: vi.fn(() => {}), show: vi.fn(async () => {}) }, setActivationPolicy: vi.fn((_policy: 'accessory' | 'prohibited' | 'regular') => {}) } } @@ -139,6 +140,42 @@ describe('applyBackgroundActivationPolicy', () => { expect(user.setActivationPolicy).not.toHaveBeenCalled() }) + it('keeps a windowless macOS serve out of the Dock until it hosts a desktop window', () => { + const app = makeApp() + expect( + applyBackgroundActivationPolicy({ + app, + env: {}, + platform: 'darwin', + serveWithoutDesktopWindow: true + }) + ).toBe(true) + expect(app.setActivationPolicy).toHaveBeenLastCalledWith('accessory') + + expect(applyServeDesktopWindowActivationPolicy({ app, env: {}, platform: 'darwin' })).toBe(true) + expect(app.setActivationPolicy).toHaveBeenLastCalledWith('regular') + expect(app.dock.show).toHaveBeenCalledOnce() + }) + + it('never gives a background serve its Dock tile back', () => { + const app = makeApp() + expect( + applyServeDesktopWindowActivationPolicy({ + app, + env: { ORCA_BACKGROUND_LAUNCH: '1' }, + platform: 'darwin' + }) + ).toBe(false) + expect( + applyServeDesktopWindowActivationPolicy({ + app, + env: { ORCA_E2E_HEADFUL: '1' }, + platform: 'darwin' + }) + ).toBe(false) + expect(app.setActivationPolicy).not.toHaveBeenCalled() + }) + it('is a no-op off macOS', () => { const app = makeApp() expect( diff --git a/src/main/window/foreground-activation-policy.ts b/src/main/window/foreground-activation-policy.ts index ff73bf2acaf..9fcc3c187bc 100644 --- a/src/main/window/foreground-activation-policy.ts +++ b/src/main/window/foreground-activation-policy.ts @@ -3,7 +3,7 @@ import { app as electronApp, type BrowserWindow } from 'electron' /** Automation must not steal OS foreground. Background/sidecar requests override E2E_FOREGROUND; the hosted-Xvfb benchmark may present explicitly after startup (tests/AGENTS.md), while automatic reveals stay suppressed. */ type ActivationPolicyApp = { - dock?: { hide: () => void } + dock?: { hide: () => void; show?: () => Promise } setActivationPolicy: (policy: 'accessory' | 'prohibited' | 'regular') => void } @@ -37,17 +37,22 @@ export function isWindowlessLaunch(env: PolicyEnv = process.env): boolean { ) } +type ActivationPolicyOptions = { + app?: ActivationPolicyApp + env?: PolicyEnv + platform?: NodeJS.Platform + /** `orca serve` with no desktop window open: a server, not an app to switch to or quit (#16061). */ + serveWithoutDesktopWindow?: boolean + warn?: (message: string, error: unknown) => void +} + /** macOS accessory policy hides Dock/menu-bar claims during windowless launch/exit while retaining programmatic activation. */ -export function applyBackgroundActivationPolicy( - options: { - app?: ActivationPolicyApp - env?: PolicyEnv - platform?: NodeJS.Platform - warn?: (message: string, error: unknown) => void - } = {} -): boolean { +export function applyBackgroundActivationPolicy(options: ActivationPolicyOptions = {}): boolean { const platform = options.platform ?? process.platform - if (platform !== 'darwin' || !isWindowlessLaunch(options.env ?? process.env)) { + if ( + platform !== 'darwin' || + !(options.serveWithoutDesktopWindow || isWindowlessLaunch(options.env ?? process.env)) + ) { return false } try { @@ -61,6 +66,28 @@ export function applyBackgroundActivationPolicy( } } +/** Gives a serve process its Dock tile and menu bar back while it hosts a desktop window. */ +export function applyServeDesktopWindowActivationPolicy( + options: Omit = {} +): boolean { + const platform = options.platform ?? process.platform + // dock.show() activates the app, so automated launches keep the accessory policy. + if (platform !== 'darwin' || isBackgroundLaunch(options.env ?? process.env)) { + return false + } + try { + const app = options.app ?? electronApp + app.setActivationPolicy('regular') + void app.dock?.show?.().catch((error: unknown) => { + options.warn?.('[window] Failed to show the Dock icon', error) + }) + return true + } catch (error) { + options.warn?.('[window] Failed to apply foreground activation policy', error) + return false + } +} + /** * Reveal a window without taking the foreground: hidden entirely when windowless, * `showInactive()` for explicitly headful E2E runs.