mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
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().
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<string, { value: string; priority: string }>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, string | undefined>>,
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user