From e882d8eda7a53d792a8629d513cb32ebb1e16dee Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:13:42 -0700 Subject: [PATCH] fix(window): pump hidden-window frames from the renderer on Windows background launches (#34197) * fix(window): repaint a never-shown background window on Windows so CDP screenshots return Windows composites nothing for an idle window that was never shown, so Page.captureScreenshot under ORCA_BACKGROUND_LAUNCH=1 waited forever once the status-bar usage chip stopped animating (#33799). * fix(window): pump hidden-window frames from the renderer on Windows background launches Main-process webContents.invalidate() did not make a never-shown Windows window produce a frame CDP can capture (verified on Windows 11). A one-frame root background flip from the preload does, so main stamps a preload flag for ORCA_BACKGROUND_LAUNCH=1 on win32 and tells the preload to stop on first show. * fix(window): stop the hidden-frame pump only on main's show signal, including after reloads A never-shown Windows window can report visible after a desktop change, so the preload no longer stops on visibilitychange; main re-sends the stop after any reload once the window has been shown. * fix(window): register the frame-pump stop with on('show') so every window double supports it createMainWindow suites simulate win32 under the runner's ORCA_BACKGROUND_LAUNCH=1, so they install the pump stop; their window doubles implement on() but not once(). --- src/main/window/createMainWindow.ts | 14 +- .../window/hidden-window-frame-pump.test.ts | 51 +++++++ src/main/window/hidden-window-frame-pump.ts | 31 ++++ src/preload/hidden-window-frame-pump.test.ts | 132 ++++++++++++++++++ src/preload/hidden-window-frame-pump.ts | 62 ++++++++ src/preload/index.ts | 15 ++ src/shared/hidden-window-frame-pump.test.ts | 23 +++ src/shared/hidden-window-frame-pump.ts | 24 ++++ 8 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 src/main/window/hidden-window-frame-pump.test.ts create mode 100644 src/main/window/hidden-window-frame-pump.ts create mode 100644 src/preload/hidden-window-frame-pump.test.ts create mode 100644 src/preload/hidden-window-frame-pump.ts create mode 100644 src/shared/hidden-window-frame-pump.test.ts create mode 100644 src/shared/hidden-window-frame-pump.ts diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 7fef8acfe92..0e6a5a72b04 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -20,6 +20,11 @@ import { mainWindowLoadErrorCode } from './main-window-load-error-code' import { installMainWindowFocusLifecycle } from './main-window-focus-lifecycle' import { installMainWindowShortcutRouting } from './main-window-shortcut-routing' import { installMainWindowStateLifecycle } from './main-window-state-lifecycle' +import { stopHiddenWindowFramePumpOnShow } from './hidden-window-frame-pump' +import { + formatHiddenWindowFramePumpArgument, + shouldPumpHiddenWindowFrames +} from '../../shared/hidden-window-frame-pump' import { forceRepaint, installMacosVisibilityRepaint, @@ -97,6 +102,7 @@ export function createMainWindow( const platformBlurOptions = blur && process.platform === 'win32' ? { backgroundMaterial: 'acrylic' as const } : {} + const pumpHiddenFrames = shouldPumpHiddenWindowFrames(process.env, process.platform) const mainWindow = new BrowserWindow({ width: savedBounds?.width ?? defaultBounds.width, height: savedBounds?.height ?? defaultBounds.height, @@ -135,7 +141,10 @@ export function createMainWindow( sandbox: true, webviewTag: true, // Pass host identity at birth so the renderer has it before first snapshot, without waiting for IPC registration. - additionalArguments: [formatBrowserClientHostIdArgument(getBrowserClientHostId())] + additionalArguments: [ + formatBrowserClientHostIdArgument(getBrowserClientHostId()), + ...(pumpHiddenFrames ? [formatHiddenWindowFramePumpArgument()] : []) + ] } }) const rendererWebContentsId = mainWindow.webContents.id @@ -183,6 +192,9 @@ export function createMainWindow( store }) installMainWindowWebviewSecurity(mainWindow) + if (pumpHiddenFrames) { + stopHiddenWindowFramePumpOnShow(mainWindow) + } const focus = installMainWindowFocusLifecycle({ isWindowClosing: state.isWindowClosing, mainWindow, diff --git a/src/main/window/hidden-window-frame-pump.test.ts b/src/main/window/hidden-window-frame-pump.test.ts new file mode 100644 index 00000000000..f2bbcd8a7f9 --- /dev/null +++ b/src/main/window/hidden-window-frame-pump.test.ts @@ -0,0 +1,51 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { HIDDEN_WINDOW_FRAME_PUMP_STOP_CHANNEL } from '../../shared/hidden-window-frame-pump' +import { stopHiddenWindowFramePumpOnShow } from './hidden-window-frame-pump' + +function fakeWindow(destroyed = false) { + const windowEvents = new EventEmitter() + const contentsEvents = new EventEmitter() + const send = vi.fn() + const window = { + on: (event: 'show', listener: () => void) => windowEvents.on(event, listener), + webContents: { + isDestroyed: () => destroyed, + on: (event: 'did-finish-load', listener: () => void) => contentsEvents.on(event, listener), + send + } + } + return { window, windowEvents, contentsEvents, send } +} + +describe('stopHiddenWindowFramePumpOnShow', () => { + it('keeps pumping across reloads while the window has never been shown', () => { + const { window, contentsEvents, send } = fakeWindow() + stopHiddenWindowFramePumpOnShow(window) + + contentsEvents.emit('did-finish-load') + + expect(send).not.toHaveBeenCalled() + }) + + it('stops on the first show and again after a later reload restarts the pump', () => { + const { window, windowEvents, contentsEvents, send } = fakeWindow() + stopHiddenWindowFramePumpOnShow(window) + + windowEvents.emit('show') + windowEvents.emit('show') + contentsEvents.emit('did-finish-load') + + expect(send).toHaveBeenCalledTimes(2) + expect(send).toHaveBeenCalledWith(HIDDEN_WINDOW_FRAME_PUMP_STOP_CHANNEL) + }) + + it('sends nothing to destroyed contents', () => { + const { window, windowEvents, send } = fakeWindow(true) + stopHiddenWindowFramePumpOnShow(window) + + windowEvents.emit('show') + + expect(send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/hidden-window-frame-pump.ts b/src/main/window/hidden-window-frame-pump.ts new file mode 100644 index 00000000000..70ab990920a --- /dev/null +++ b/src/main/window/hidden-window-frame-pump.ts @@ -0,0 +1,31 @@ +import { HIDDEN_WINDOW_FRAME_PUMP_STOP_CHANNEL } from '../../shared/hidden-window-frame-pump' + +type FramePumpWindow = { + on(event: 'show', listener: () => void): unknown + webContents: { + isDestroyed?(): boolean + on(event: 'did-finish-load', listener: () => void): unknown + send(channel: string): void + } +} + +/** The preload pumps from birth; stop it on first show and after any later reload restarts it. */ +export function stopHiddenWindowFramePumpOnShow(window: FramePumpWindow): void { + let shown = false + const stop = (): void => { + if (window.webContents.isDestroyed?.() !== true) { + window.webContents.send(HIDDEN_WINDOW_FRAME_PUMP_STOP_CHANNEL) + } + } + window.on('show', () => { + if (!shown) { + shown = true + stop() + } + }) + window.webContents.on('did-finish-load', () => { + if (shown) { + stop() + } + }) +} diff --git a/src/preload/hidden-window-frame-pump.test.ts b/src/preload/hidden-window-frame-pump.test.ts new file mode 100644 index 00000000000..d86e9665ee2 --- /dev/null +++ b/src/preload/hidden-window-frame-pump.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import { + HIDDEN_WINDOW_FRAME_PUMP_INTERVAL_MS, + formatHiddenWindowFramePumpArgument +} from '../shared/hidden-window-frame-pump' +import { + installHiddenWindowFramePump, + startHiddenWindowFramePump +} from './hidden-window-frame-pump' + +function harness(initial?: { value: string; priority: string }) { + const props = new Map() + if (initial) { + props.set('background-color', initial) + } + const history: string[] = [] + const style = { + getPropertyValue: (name: string) => props.get(name)?.value ?? '', + getPropertyPriority: (name: string) => props.get(name)?.priority ?? '', + setProperty: (name: string, value: string | null, priority = '') => { + props.set(name, { value: value ?? '', priority }) + history.push(`set ${value} ${priority}`.trim()) + return undefined + }, + removeProperty: (name: string) => { + props.delete(name) + history.push('remove') + return '' + } + } + const frames: (() => void)[] = [] + let tick: (() => void) | null = null + let intervalMs = 0 + let cancelled = false + const stop = startHiddenWindowFramePump({ + rootStyle: () => style, + requestAnimationFrame: (callback) => frames.push(callback), + every: (callback, ms) => { + tick = callback + intervalMs = ms + return () => { + cancelled = true + } + } + }) + const runFrame = () => frames.shift()?.() + return { + props, + history, + frames, + stop, + tick: () => tick?.(), + runFrame, + intervalMs: () => intervalMs, + cancelled: () => cancelled + } +} + +describe('startHiddenWindowFramePump', () => { + it('damages exactly one frame per interval and removes the style it added', () => { + const h = harness() + expect(h.intervalMs()).toBe(HIDDEN_WINDOW_FRAME_PUMP_INTERVAL_MS) + + h.tick() + h.runFrame() + expect(h.props.get('background-color')?.value).toBe('rgb(1, 1, 1)') + h.runFrame() + + expect(h.props.has('background-color')).toBe(false) + expect(h.history).toEqual(['set rgb(1, 1, 1)', 'remove']) + }) + + it('restores an existing inline background and its priority exactly', () => { + const h = harness({ value: 'red', priority: 'important' }) + + h.tick() + h.runFrame() + h.runFrame() + + expect(h.props.get('background-color')).toEqual({ value: 'red', priority: 'important' }) + }) + + it('does not start a second flip while one is in flight', () => { + const h = harness() + + h.tick() + h.tick() + + expect(h.frames).toHaveLength(1) + }) + + it('cancels the interval on stop but still restores a flip in flight', () => { + const h = harness() + h.tick() + h.runFrame() + + h.stop() + h.runFrame() + h.tick() + + expect(h.cancelled()).toBe(true) + expect(h.props.has('background-color')).toBe(false) + expect(h.frames).toHaveLength(0) + }) +}) + +describe('installHiddenWindowFramePump', () => { + function deps() { + const every = vi.fn((_callback: () => void, _ms: number) => vi.fn()) + return { rootStyle: () => null, requestAnimationFrame: vi.fn(), every } + } + + it('does not start without the flag main stamps', () => { + const d = deps() + const onStopRequest = vi.fn() + installHiddenWindowFramePump(['--other'], onStopRequest, d) + + expect(d.every).not.toHaveBeenCalled() + expect(onStopRequest).not.toHaveBeenCalled() + }) + + it('starts with the flag and hands main the stop', () => { + const d = deps() + let stop: (() => void) | undefined + installHiddenWindowFramePump([formatHiddenWindowFramePumpArgument()], (s) => (stop = s), d) + const cancel = d.every.mock.results[0]?.value + + stop?.() + + expect(cancel).toHaveBeenCalledOnce() + }) +}) diff --git a/src/preload/hidden-window-frame-pump.ts b/src/preload/hidden-window-frame-pump.ts new file mode 100644 index 00000000000..94e415f8bbe --- /dev/null +++ b/src/preload/hidden-window-frame-pump.ts @@ -0,0 +1,62 @@ +import { + HIDDEN_WINDOW_FRAME_PUMP_INTERVAL_MS, + hasHiddenWindowFramePumpArgument +} from '../shared/hidden-window-frame-pump' + +// Near-black so the single damaged frame is indistinguishable even if the window were visible. +const FLIP_COLOR = 'rgb(1, 1, 1)' + +type FramePumpStyle = Pick< + CSSStyleDeclaration, + 'getPropertyValue' | 'getPropertyPriority' | 'setProperty' | 'removeProperty' +> + +export type HiddenWindowFramePumpDeps = { + rootStyle: () => FramePumpStyle | null + requestAnimationFrame: (callback: () => void) => unknown + /** Runs callback every ms; returns the cancel function. */ + every: (callback: () => void, ms: number) => () => void +} + +/** Damage one frame per interval by flipping the root background, then restore the inline style exactly. */ +export function startHiddenWindowFramePump(deps: HiddenWindowFramePumpDeps): () => void { + let stopped = false + let flipping = false + const cancel = deps.every(() => { + const style = deps.rootStyle() + if (stopped || flipping || !style) { + return + } + flipping = true + deps.requestAnimationFrame(() => { + const previousValue = style.getPropertyValue('background-color') + const previousPriority = style.getPropertyPriority('background-color') + style.setProperty('background-color', FLIP_COLOR, previousPriority) + deps.requestAnimationFrame(() => { + if (previousValue) { + style.setProperty('background-color', previousValue, previousPriority) + } else { + style.removeProperty('background-color') + } + flipping = false + }) + }) + }, HIDDEN_WINDOW_FRAME_PUMP_INTERVAL_MS) + return () => { + if (!stopped) { + stopped = true + cancel() + } + } +} + +/** Only main's stop ends the pump; Windows can report a never-shown window visible after a desktop change. */ +export function installHiddenWindowFramePump( + argv: readonly string[], + onStopRequest: (stop: () => void) => void, + deps: HiddenWindowFramePumpDeps +): void { + if (hasHiddenWindowFramePumpArgument(argv)) { + onStopRequest(startHiddenWindowFramePump(deps)) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 1dbe3bfd962..942a5e24c8e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron' +import { HIDDEN_WINDOW_FRAME_PUMP_STOP_CHANNEL } from '../shared/hidden-window-frame-pump' +import { installHiddenWindowFramePump } from './hidden-window-frame-pump' import type { PreloadApi } from './api-types' import { installBrowserFindListener } from './preload-runtime-support' import { appApi } from './api/app-bridge' @@ -197,3 +199,16 @@ if (process.contextIsolated) { } else { window.api = api } + +installHiddenWindowFramePump( + process.argv, + (stop) => ipcRenderer.once(HIDDEN_WINDOW_FRAME_PUMP_STOP_CHANNEL, stop), + { + rootStyle: () => document.documentElement?.style ?? null, + requestAnimationFrame: (callback) => window.requestAnimationFrame(callback), + every: (callback, ms) => { + const handle = window.setInterval(callback, ms) + return () => window.clearInterval(handle) + } + } +) diff --git a/src/shared/hidden-window-frame-pump.test.ts b/src/shared/hidden-window-frame-pump.test.ts new file mode 100644 index 00000000000..17c37147749 --- /dev/null +++ b/src/shared/hidden-window-frame-pump.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { + formatHiddenWindowFramePumpArgument, + hasHiddenWindowFramePumpArgument, + shouldPumpHiddenWindowFrames +} from './hidden-window-frame-pump' + +describe('shouldPumpHiddenWindowFrames', () => { + it('pumps only a Windows background launch', () => { + expect(shouldPumpHiddenWindowFrames({ ORCA_BACKGROUND_LAUNCH: '1' }, 'win32')).toBe(true) + expect(shouldPumpHiddenWindowFrames({ ORCA_BACKGROUND_LAUNCH: '1' }, 'darwin')).toBe(false) + expect(shouldPumpHiddenWindowFrames({ ORCA_BACKGROUND_LAUNCH: '1' }, 'linux')).toBe(false) + expect(shouldPumpHiddenWindowFrames({}, 'win32')).toBe(false) + expect(shouldPumpHiddenWindowFrames({ ORCA_BACKGROUND_LAUNCH: '0' }, 'win32')).toBe(false) + }) + + it('reads back only the argument main stamped', () => { + expect(hasHiddenWindowFramePumpArgument(['--x', formatHiddenWindowFramePumpArgument()])).toBe( + true + ) + expect(hasHiddenWindowFramePumpArgument(['--orca-browser-client-host-id=abc'])).toBe(false) + }) +}) diff --git a/src/shared/hidden-window-frame-pump.ts b/src/shared/hidden-window-frame-pump.ts new file mode 100644 index 00000000000..4f011954610 --- /dev/null +++ b/src/shared/hidden-window-frame-pump.ts @@ -0,0 +1,24 @@ +/** + * Windows produces no compositor frame for an idle never-shown window, and main-process + * webContents.invalidate() does not change that, so CDP Page.captureScreenshot under + * ORCA_BACKGROUND_LAUNCH=1 waits forever. A one-frame renderer style flip does produce a frame. + */ +const HIDDEN_WINDOW_FRAME_PUMP_ARGUMENT = '--orca-hidden-window-frame-pump' + +export const HIDDEN_WINDOW_FRAME_PUMP_STOP_CHANNEL = 'window:hidden-frame-pump-stop' +export const HIDDEN_WINDOW_FRAME_PUMP_INTERVAL_MS = 750 + +export function shouldPumpHiddenWindowFrames( + env: Readonly>, + platform: NodeJS.Platform +): boolean { + return platform === 'win32' && env.ORCA_BACKGROUND_LAUNCH === '1' +} + +export function formatHiddenWindowFramePumpArgument(): string { + return HIDDEN_WINDOW_FRAME_PUMP_ARGUMENT +} + +export function hasHiddenWindowFramePumpArgument(argv: readonly string[]): boolean { + return argv.includes(HIDDEN_WINDOW_FRAME_PUMP_ARGUMENT) +}