mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(macos): prevent stale UI surfaces after wake (#11226)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -364,7 +364,7 @@ describe('createMainWindow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps main-window background throttling enabled while repainting macOS visibility transitions', () => {
|
||||
it('keeps macOS background throttling enabled while repainting visibility transitions', () => {
|
||||
vi.useFakeTimers()
|
||||
const windowHandlers = new Map<string, ((...args: any[]) => void)[]>()
|
||||
let windowSize: [number, number] = [1200, 800]
|
||||
@@ -405,11 +405,8 @@ describe('createMainWindow', () => {
|
||||
|
||||
withPlatform('darwin', () => createMainWindow(null))
|
||||
|
||||
// Why: throttling-off pins visibilityState 'visible' and renders occluded
|
||||
// windows at full rate; this guards against reintroducing it.
|
||||
expect(webContents.setBackgroundThrottling).not.toHaveBeenCalledWith(false)
|
||||
|
||||
expect(webContents.setBackgroundThrottling).toHaveBeenCalledWith(true)
|
||||
expect(webContents.setBackgroundThrottling).not.toHaveBeenCalledWith(false)
|
||||
expect(windowHandlers.get('restore')).toHaveLength(1)
|
||||
expect(windowHandlers.get('show')).toHaveLength(1)
|
||||
expect(windowHandlers.get('focus')).toHaveLength(1)
|
||||
@@ -493,8 +490,7 @@ describe('createMainWindow', () => {
|
||||
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
|
||||
expect(webContents.invalidate).not.toHaveBeenCalled()
|
||||
|
||||
// The genuine reveal (matching sender) runs the full repaint: invalidate + the size jiggle
|
||||
// that recomputes the stale dvh layout — the recovery bare focus/invalidate misses.
|
||||
// The genuine reveal runs the pre-Tahoe compositor jiggle that bare focus avoids.
|
||||
revealHandler?.({ sender: webContents } as never)
|
||||
expect(webContents.invalidate).toHaveBeenCalledTimes(1)
|
||||
// Why: the nudge is deferred off the event dispatch turn.
|
||||
@@ -535,9 +531,7 @@ describe('createMainWindow', () => {
|
||||
send: vi.fn(),
|
||||
isDevToolsOpened: vi.fn(),
|
||||
openDevTools: vi.fn(),
|
||||
closeDevTools: vi.fn(),
|
||||
enableDeviceEmulation: vi.fn(),
|
||||
disableDeviceEmulation: vi.fn()
|
||||
closeDevTools: vi.fn()
|
||||
}
|
||||
const browserWindowInstance = {
|
||||
webContents,
|
||||
@@ -550,8 +544,6 @@ describe('createMainWindow', () => {
|
||||
isMaximized: vi.fn(() => false),
|
||||
isFullScreen: vi.fn(() => false),
|
||||
getSize: vi.fn(() => [1200, 800]),
|
||||
getContentSize: vi.fn(() => [1200, 800]),
|
||||
getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1200, height: 840 })),
|
||||
setSize: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
show: vi.fn(),
|
||||
@@ -571,21 +563,9 @@ describe('createMainWindow', () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(webContents.invalidate).toHaveBeenCalledTimes(2)
|
||||
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
|
||||
|
||||
// Why (STA-2383): invalidate repaints but never reflows, so Tahoe still has to recompute the
|
||||
// dvh root — via the emulated viewport, which leaves the deadlock-prone frame untouched.
|
||||
expect(webContents.enableDeviceEmulation).toHaveBeenCalledWith({
|
||||
screenPosition: 'desktop',
|
||||
screenSize: { width: 0, height: 0 },
|
||||
deviceScaleFactor: 2.25,
|
||||
viewSize: { width: 1200, height: 800 },
|
||||
scale: 1
|
||||
})
|
||||
expect(webContents.disableDeviceEmulation).toHaveBeenCalled()
|
||||
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reflows a maximized macOS 26 window, which the size nudge had to skip', () => {
|
||||
it('invalidates a maximized macOS 26 window without changing its frame', () => {
|
||||
vi.useFakeTimers()
|
||||
macosTahoeMock.value = true
|
||||
const windowHandlers = new Map<string, ((...args: any[]) => void)[]>()
|
||||
@@ -599,9 +579,7 @@ describe('createMainWindow', () => {
|
||||
send: vi.fn(),
|
||||
isDevToolsOpened: vi.fn(),
|
||||
openDevTools: vi.fn(),
|
||||
closeDevTools: vi.fn(),
|
||||
enableDeviceEmulation: vi.fn(),
|
||||
disableDeviceEmulation: vi.fn()
|
||||
closeDevTools: vi.fn()
|
||||
}
|
||||
const browserWindowInstance = {
|
||||
webContents,
|
||||
@@ -611,13 +589,9 @@ describe('createMainWindow', () => {
|
||||
windowHandlers.set(event, handlers)
|
||||
}),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
// Why: the maximized/fullscreen bail-out only ever protected setSize from un-maximizing
|
||||
// the window; emulation leaves the frame alone, so the reflow must still happen here.
|
||||
isMaximized: vi.fn(() => true),
|
||||
isFullScreen: vi.fn(() => true),
|
||||
getSize: vi.fn(() => [1200, 800]),
|
||||
getContentSize: vi.fn(() => [1200, 800]),
|
||||
getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1200, height: 840 })),
|
||||
setSize: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
show: vi.fn(),
|
||||
@@ -633,12 +607,11 @@ describe('createMainWindow', () => {
|
||||
windowHandlers.get('show')?.[0]?.()
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(webContents.enableDeviceEmulation).toHaveBeenCalled()
|
||||
expect(webContents.disableDeviceEmulation).toHaveBeenCalled()
|
||||
expect(webContents.invalidate).toHaveBeenCalledTimes(2)
|
||||
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reflows without the size nudge when macOS 26 wakes from sleep', () => {
|
||||
it('invalidates without frame or device emulation when macOS 26 wakes from sleep', () => {
|
||||
vi.useFakeTimers()
|
||||
macosTahoeMock.value = true
|
||||
const windowHandlers = new Map<string, ((...args: any[]) => void)[]>()
|
||||
@@ -667,8 +640,6 @@ describe('createMainWindow', () => {
|
||||
isMaximized: vi.fn(() => false),
|
||||
isFullScreen: vi.fn(() => false),
|
||||
getSize: vi.fn(() => [1200, 800]),
|
||||
getContentSize: vi.fn(() => [1200, 800]),
|
||||
getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1200, height: 840 })),
|
||||
setSize: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
show: vi.fn(),
|
||||
@@ -681,8 +652,6 @@ describe('createMainWindow', () => {
|
||||
|
||||
withPlatform('darwin', () => createMainWindow(null))
|
||||
|
||||
// Why: 'resume' is the other AppKit dispatch context implicated in the 109-minute freeze,
|
||||
// so it must take the frame-free path too — not just show/restore.
|
||||
const resumeHandler = powerMonitorOnMock.mock.calls.find(
|
||||
([event]) => event === 'resume'
|
||||
)?.[1] as (() => void) | undefined
|
||||
@@ -692,14 +661,8 @@ describe('createMainWindow', () => {
|
||||
expect(webContents.invalidate).toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(browserWindowInstance.setSize).not.toHaveBeenCalled()
|
||||
expect(webContents.enableDeviceEmulation).toHaveBeenCalledWith({
|
||||
screenPosition: 'desktop',
|
||||
screenSize: { width: 0, height: 0 },
|
||||
deviceScaleFactor: 2.25,
|
||||
viewSize: { width: 1200, height: 800 },
|
||||
scale: 1
|
||||
})
|
||||
expect(webContents.disableDeviceEmulation).toHaveBeenCalled()
|
||||
expect(webContents.enableDeviceEmulation).not.toHaveBeenCalled()
|
||||
expect(webContents.disableDeviceEmulation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supports all minus key variants for terminal zoom out', () => {
|
||||
|
||||
@@ -49,7 +49,6 @@ import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation'
|
||||
import { closeDashboardPopout } from './dashboard-popout-window'
|
||||
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
|
||||
import { isMacosTahoeOrNewer } from './macos-tahoe-release'
|
||||
import { reflowRendererViewport } from './renderer-viewport-reflow'
|
||||
import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard'
|
||||
|
||||
// Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline.
|
||||
@@ -62,12 +61,9 @@ function forceRepaint(window: BrowserWindow): void {
|
||||
return
|
||||
}
|
||||
window.webContents.invalidate()
|
||||
// Why: macOS 26 scene-backed windows deadlock the main thread on frame mutation, but invalidate
|
||||
// alone never reflows the dvh root (STA-2383); emulation reflows without touching the frame.
|
||||
// Runs before the maximized/fullscreen bail-out below, which only exists to protect setSize —
|
||||
// emulation leaves those states intact, and a maximized window goes stale just the same.
|
||||
// Why: macOS 26 scene-backed windows deadlock on frame mutation, and device emulation can
|
||||
// strand the compositor after wake. The native shell no longer relies on dvh reflow.
|
||||
if (isMacosTahoeOrNewer()) {
|
||||
reflowRendererViewport(window)
|
||||
return
|
||||
}
|
||||
if (window.isMaximized() || window.isFullScreen() || activeRepaintJiggles.has(window)) {
|
||||
@@ -125,10 +121,8 @@ function installMacosVisibilityRepaint(window: BrowserWindow): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Why (STA-2383): occlusion-uncover fires no restore/show, so the renderer relays its genuine
|
||||
// hidden→visible reveal instead. Unlike a bare focus (every Cmd+Tab, window never hidden), this
|
||||
// only fires when the window was actually occluded/throttled — exactly when the stale dvh layout
|
||||
// stranded the bottom status bar off-screen — so the full repaint's size jiggle is warranted.
|
||||
// Why: occlusion reveal can fire no restore/show, so preserve the renderer relay without
|
||||
// trusting events from another window.
|
||||
const onRendererRevealed = (event: Electron.IpcMainEvent): void => {
|
||||
if (window.isDestroyed() || window.webContents.isDestroyed()) {
|
||||
return
|
||||
@@ -142,7 +136,7 @@ function installMacosVisibilityRepaint(window: BrowserWindow): void {
|
||||
|
||||
window.on('restore', repaintAfterVisibilityTransition)
|
||||
window.on('show', repaintAfterVisibilityTransition)
|
||||
// Why: occlusion-uncover fires no restore/show, only focus; invalidate only — the setSize jiggle would SIGWINCH every terminal on Cmd+Tab. The renderer-reveal relay above covers the stale-layout recovery that invalidate alone misses.
|
||||
// Why: occlusion-uncover can fire only focus; invalidate without resizing terminals on Cmd+Tab.
|
||||
window.on('focus', () => {
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
window.webContents.invalidate()
|
||||
@@ -303,7 +297,8 @@ export function createMainWindow(
|
||||
setTrustedUIRendererWebContentsId(rendererWebContentsId)
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// Why: throttle the main window while hidden (guests self-unthrottle); toggle only while visible or Chromium blanks the surface (electron#42378).
|
||||
// Why: preserve hidden-window power savings; stable native sizing and frame-only invalidation
|
||||
// make wake recovery independent of the throttled viewport.
|
||||
mainWindow.webContents.setBackgroundThrottling(true)
|
||||
installMacosVisibilityRepaint(mainWindow)
|
||||
}
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
const scaleFactorMock = { value: 1 }
|
||||
vi.mock('electron', () => ({
|
||||
screen: { getDisplayMatching: vi.fn(() => ({ scaleFactor: scaleFactorMock.value })) }
|
||||
}))
|
||||
|
||||
import {
|
||||
clearCrashBreadcrumbsForTest,
|
||||
getCrashBreadcrumbSnapshot
|
||||
} from '../crash-reporting/crash-breadcrumb-store'
|
||||
import {
|
||||
reflowRendererViewport,
|
||||
VIEWPORT_REFLOW_RESTORE_ATTEMPTS,
|
||||
VIEWPORT_REFLOW_SETTLE_MS
|
||||
} from './renderer-viewport-reflow'
|
||||
|
||||
function createWindow(overrides: Record<string, unknown> = {}): BrowserWindow {
|
||||
const webContents = {
|
||||
isDestroyed: vi.fn(() => false),
|
||||
enableDeviceEmulation: vi.fn(),
|
||||
disableDeviceEmulation: vi.fn()
|
||||
}
|
||||
return {
|
||||
webContents,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getContentSize: vi.fn(() => [1200, 800]),
|
||||
getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1200, height: 840 })),
|
||||
setSize: vi.fn(),
|
||||
setBounds: vi.fn(),
|
||||
...overrides
|
||||
} as unknown as BrowserWindow
|
||||
}
|
||||
|
||||
describe('reflowRendererViewport', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
scaleFactorMock.value = 1
|
||||
clearCrashBreadcrumbsForTest()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('never mutates the native window frame', () => {
|
||||
const window = createWindow()
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.setSize).not.toHaveBeenCalled()
|
||||
expect(window.setBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers the emulation off the caller stack, then restores the real viewport', () => {
|
||||
const window = createWindow()
|
||||
reflowRendererViewport(window)
|
||||
// Why: nothing may run while AppKit's callout frame is still on the stack.
|
||||
expect(window.webContents.enableDeviceEmulation).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(0)
|
||||
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledWith({
|
||||
screenPosition: 'desktop',
|
||||
screenSize: { width: 0, height: 0 },
|
||||
deviceScaleFactor: 1.25,
|
||||
viewSize: { width: 1200, height: 800 },
|
||||
scale: 1
|
||||
})
|
||||
expect(window.webContents.disableDeviceEmulation).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS)
|
||||
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Why: a CSS-pixel delta re-grids terminals sitting on an xterm row boundary, so the emulated
|
||||
// viewport must match the real one exactly — the scale factor is the only thing allowed to move.
|
||||
it('emulates the real viewport size so no terminal re-grids mid-reflow', () => {
|
||||
const window = createWindow()
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(0)
|
||||
const [params] = (window.webContents.enableDeviceEmulation as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0] as [Electron.Parameters]
|
||||
expect(params.viewSize).toEqual({ width: 1200, height: 800 })
|
||||
expect(params.scale).toBe(1)
|
||||
})
|
||||
|
||||
// Why: Blink applies screenSize/viewPosition ahead of the desktop branch, so overriding them
|
||||
// moves screen.width and window.screenX for the whole hold — a context menu opened mid-reflow
|
||||
// would land at the wrong coordinates. Only the scale factor may move.
|
||||
it('leaves screen geometry and window position untouched while emulating', () => {
|
||||
const window = createWindow()
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(0)
|
||||
const [params] = (window.webContents.enableDeviceEmulation as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0] as [Electron.Parameters]
|
||||
expect(params.screenSize).toEqual({ width: 0, height: 0 })
|
||||
expect(params).not.toHaveProperty('viewPosition')
|
||||
})
|
||||
|
||||
// Why: a constant scale factor would equal the real one on a 1.25x display and reflow nothing.
|
||||
it('offsets from the display the window is actually on', () => {
|
||||
scaleFactorMock.value = 1.25
|
||||
const window = createWindow()
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(0)
|
||||
const [params] = (window.webContents.enableDeviceEmulation as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0] as [Electron.Parameters]
|
||||
expect(params.deviceScaleFactor).toBe(1.5)
|
||||
})
|
||||
|
||||
// Why: a display reporting 0 would emulate 0.25 and shrink everything.
|
||||
it('falls back to 1x when the display reports no scale factor', () => {
|
||||
scaleFactorMock.value = 0
|
||||
const window = createWindow()
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(0)
|
||||
const [params] = (window.webContents.enableDeviceEmulation as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0] as [Electron.Parameters]
|
||||
expect(params.deviceScaleFactor).toBe(1.25)
|
||||
})
|
||||
|
||||
it('collapses a burst of reveals into one emulation cycle', () => {
|
||||
const window = createWindow()
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
reflowRendererViewport(window)
|
||||
}
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(1)
|
||||
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-arms once the cycle finishes', () => {
|
||||
const window = createWindow()
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(2)
|
||||
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('skips a window destroyed before the deferred turn runs', () => {
|
||||
const window = createWindow()
|
||||
reflowRendererViewport(window)
|
||||
vi.mocked(window.isDestroyed).mockReturnValue(true)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.webContents.enableDeviceEmulation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips a webContents destroyed before the deferred turn runs', () => {
|
||||
const window = createWindow()
|
||||
reflowRendererViewport(window)
|
||||
vi.mocked(window.webContents.isDestroyed).mockReturnValue(true)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.webContents.enableDeviceEmulation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not leave emulation on when the window dies mid-cycle', () => {
|
||||
const window = createWindow()
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(0)
|
||||
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.mocked(window.isDestroyed).mockReturnValue(true)
|
||||
expect(() => vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS)).not.toThrow()
|
||||
expect(window.webContents.disableDeviceEmulation).not.toHaveBeenCalled()
|
||||
|
||||
// Why: a stuck latch would silently disable every later reflow for this window.
|
||||
vi.mocked(window.isDestroyed).mockReturnValue(false)
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('recovers the latch when enabling emulation throws', () => {
|
||||
const window = createWindow()
|
||||
vi.mocked(window.webContents.enableDeviceEmulation).mockImplementationOnce(() => {
|
||||
throw new Error('Object has been destroyed')
|
||||
})
|
||||
reflowRendererViewport(window)
|
||||
expect(() => vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)).not.toThrow()
|
||||
// Why: nothing was applied, so no restore should be attempted.
|
||||
expect(window.webContents.disableDeviceEmulation).not.toHaveBeenCalled()
|
||||
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries the restore rather than stranding a live renderer at the overshot viewport', () => {
|
||||
const window = createWindow()
|
||||
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementationOnce(() => {
|
||||
throw new Error('transient failure')
|
||||
})
|
||||
reflowRendererViewport(window)
|
||||
expect(() => vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)).not.toThrow()
|
||||
|
||||
// Why: the webContents is still alive, so the emulated viewport is still applied — giving up
|
||||
// here would leave the renderer stuck at the overshot height forever.
|
||||
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(1)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Why: the retry succeeded, so the latch is free and later reveals still reflow.
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('holds the latch while a restore is still being retried', () => {
|
||||
const window = createWindow()
|
||||
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => {
|
||||
throw new Error('still failing')
|
||||
})
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
|
||||
// Why: stacking a second emulation over an unrestored viewport would compound the overshoot.
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops retrying the restore and re-arms after the attempt budget', () => {
|
||||
const window = createWindow()
|
||||
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => {
|
||||
throw new Error('always fails')
|
||||
})
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS * (VIEWPORT_REFLOW_RESTORE_ATTEMPTS + 4))
|
||||
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(
|
||||
VIEWPORT_REFLOW_RESTORE_ATTEMPTS + 1
|
||||
)
|
||||
|
||||
// Why: an unbounded retry would pin the latch and silently kill every later reflow.
|
||||
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => undefined)
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.webContents.enableDeviceEmulation).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('leaves evidence when it gives up with the viewport still emulated', () => {
|
||||
const window = createWindow()
|
||||
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => {
|
||||
throw new Error('always fails')
|
||||
})
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS * (VIEWPORT_REFLOW_RESTORE_ATTEMPTS + 4))
|
||||
|
||||
expect(getCrashBreadcrumbSnapshot().map((crumb) => crumb.name)).toEqual([
|
||||
'viewport_reflow_restore_failed'
|
||||
])
|
||||
})
|
||||
|
||||
it('records nothing when the restore succeeds', () => {
|
||||
const window = createWindow()
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS * (VIEWPORT_REFLOW_RESTORE_ATTEMPTS + 4))
|
||||
|
||||
expect(getCrashBreadcrumbSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('abandons the restore once the webContents is gone', () => {
|
||||
const window = createWindow()
|
||||
vi.mocked(window.webContents.disableDeviceEmulation).mockImplementation(() => {
|
||||
throw new Error('always fails')
|
||||
})
|
||||
reflowRendererViewport(window)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS + 1)
|
||||
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Why: a destroyed renderer takes its emulated viewport with it; retrying is pointless.
|
||||
vi.mocked(window.webContents.isDestroyed).mockReturnValue(true)
|
||||
vi.advanceTimersByTime(VIEWPORT_REFLOW_SETTLE_MS * 5)
|
||||
expect(window.webContents.disableDeviceEmulation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import { screen } from 'electron'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
|
||||
|
||||
// Why: long enough for the emulated scale factor to reach the renderer and drive a relayout.
|
||||
export const VIEWPORT_REFLOW_SETTLE_MS = 32
|
||||
|
||||
// Why: any delta re-runs layout; 0.25 stays clear of float-compare noise against the real factor.
|
||||
const VIEWPORT_REFLOW_SCALE_DELTA = 0.25
|
||||
|
||||
// Why: a restore that keeps failing on a live webContents would loop forever; give up and
|
||||
// release the latch so a later reveal can try a fresh cycle.
|
||||
export const VIEWPORT_REFLOW_RESTORE_ATTEMPTS = 3
|
||||
|
||||
// Why: overlapping reveals must not stack emulation calls; the outer one owns the restore.
|
||||
const activeViewportReflows = new WeakSet<BrowserWindow>()
|
||||
|
||||
function isWindowGone(window: BrowserWindow): boolean {
|
||||
return window.isDestroyed() || window.webContents.isDestroyed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the renderer to recompute its viewport without touching the native window frame.
|
||||
*
|
||||
* Why: `webContents.invalidate()` repaints but never reflows, so a stale `dvh` root keeps the
|
||||
* status bar clipped off-screen (STA-2383). The frame jiggle that used to fix that mutates
|
||||
* NSWindow, which self-deadlocks the main thread on macOS 26's scene-backed windows. Device
|
||||
* emulation drives the same resize through the compositor instead — real reflow, no scene update.
|
||||
*
|
||||
* Why scale factor and not a +1px viewport: a one-pixel height delta changes the CSS box, so a
|
||||
* terminal sitting just under an xterm row boundary gains a row, and the pane fit observer reads
|
||||
* that transient grid as stable and forwards a real PTY resize — then reverses it on restore.
|
||||
* Measured 3/54 window heights SIGWINCHing twice per reveal. A scale-factor delta re-runs layout
|
||||
* with byte-identical CSS geometry: 0/54, with no WebGL atlas rebuild or context loss.
|
||||
*/
|
||||
export function reflowRendererViewport(window: BrowserWindow): void {
|
||||
if (activeViewportReflows.has(window) || isWindowGone(window)) {
|
||||
return
|
||||
}
|
||||
activeViewportReflows.add(window)
|
||||
// Why: reveal/resume fire from inside AppKit's dispatch; start on a fresh turn so nothing
|
||||
// native runs while that callout frame is still on the stack.
|
||||
setTimeout(() => {
|
||||
if (isWindowGone(window)) {
|
||||
activeViewportReflows.delete(window)
|
||||
return
|
||||
}
|
||||
let emulating = false
|
||||
try {
|
||||
const [width, height] = window.getContentSize()
|
||||
// Why: the real viewport, unchanged — only the scale factor moves.
|
||||
const viewSize = { width, height }
|
||||
// Why: emulating the current factor is a no-op, so offset from this window's own display
|
||||
// rather than a constant, which would match on a 1.25x screen and reflow nothing.
|
||||
const realScaleFactor = screen.getDisplayMatching(window.getBounds()).scaleFactor || 1
|
||||
// Why: Blink reads screenSize/viewPosition before the desktop branch, so 'desktop' does
|
||||
// not make them inert despite the mobile-only docs. Passing the content size and 0,0
|
||||
// moved screen.width and window.screenX for the 32ms hold, misplacing anything that
|
||||
// translates screen coords (the BrowserPane context menu). Empty screenSize means "no
|
||||
// override", and an omitted viewPosition stays nullopt so the real position survives --
|
||||
// Electron's types require the key, but its converter only assigns when present.
|
||||
window.webContents.enableDeviceEmulation({
|
||||
screenPosition: 'desktop',
|
||||
screenSize: { width: 0, height: 0 },
|
||||
deviceScaleFactor: realScaleFactor + VIEWPORT_REFLOW_SCALE_DELTA,
|
||||
viewSize,
|
||||
scale: 1
|
||||
} as Parameters<typeof window.webContents.enableDeviceEmulation>[0])
|
||||
emulating = true
|
||||
} catch {
|
||||
// Why: a teardown race can destroy the webContents mid-call; nothing was applied.
|
||||
}
|
||||
if (!emulating) {
|
||||
activeViewportReflows.delete(window)
|
||||
return
|
||||
}
|
||||
// Why: emulation must always be undone — leaving it on strands the renderer at the wrong
|
||||
// scale factor for the rest of the window's life.
|
||||
restoreRealViewport(window, 0)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function restoreRealViewport(window: BrowserWindow, attempt: number): void {
|
||||
setTimeout(() => {
|
||||
if (isWindowGone(window)) {
|
||||
// Why: the emulated viewport dies with the webContents, so there is nothing to restore.
|
||||
activeViewportReflows.delete(window)
|
||||
return
|
||||
}
|
||||
try {
|
||||
window.webContents.disableDeviceEmulation()
|
||||
activeViewportReflows.delete(window)
|
||||
} catch {
|
||||
// Why: the webContents is still alive, so the renderer is stuck at the emulated scale;
|
||||
// keep retrying and hold the latch so no second cycle stacks on the unrestored state.
|
||||
if (attempt >= VIEWPORT_REFLOW_RESTORE_ATTEMPTS) {
|
||||
// Why: releasing the latch beats pinning it — a later reveal can still restore. But the
|
||||
// renderer is stranded at the wrong scale factor until one happens, so leave evidence
|
||||
// rather than let a permanently-emulated viewport look like a rendering bug.
|
||||
recordCrashBreadcrumb('viewport_reflow_restore_failed', { attempts: attempt + 1 })
|
||||
activeViewportReflows.delete(window)
|
||||
return
|
||||
}
|
||||
restoreRealViewport(window, attempt + 1)
|
||||
}
|
||||
}, VIEWPORT_REFLOW_SETTLE_MS)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html class="native-shell">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Orca</title>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html class="native-shell">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Orca Agent Dashboard</title>
|
||||
|
||||
@@ -2193,7 +2193,7 @@ function App(): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
ref={setAppRootNode}
|
||||
className="flex flex-col h-dvh w-screen overflow-hidden"
|
||||
className="app-layout"
|
||||
style={
|
||||
{
|
||||
'--collapsed-sidebar-header-width': `${collapsedSidebarHeaderWidth}px`,
|
||||
|
||||
@@ -450,10 +450,7 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
/* Why: dvh tracks the visible viewport so the web app shell isn't taller
|
||||
than the screen when a mobile browser's URL bar is shown (100vh = the
|
||||
large viewport, which forces a ~40-100px scroll). In Electron there is
|
||||
no browser chrome, so dvh resolves identically to the window height. */
|
||||
/* Why: mobile browser chrome changes the visible viewport while open. */
|
||||
height: 100dvh;
|
||||
font-family: var(
|
||||
--app-font-family,
|
||||
@@ -693,6 +690,14 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Why: native windows have a stable content box and need no wake-time viewport reflow. */
|
||||
html.native-shell,
|
||||
html.native-shell body,
|
||||
html.native-shell #root,
|
||||
html.native-shell .app-layout {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Tab-group split resize handle — wide hit area, visible center line. */
|
||||
.tab-group-split-resize-handle {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -12,7 +12,7 @@ function cssBlock(css: string, selector: string): string {
|
||||
}
|
||||
|
||||
describe('web viewport shell', () => {
|
||||
it('uses dynamic viewport height for document and app shell containers', () => {
|
||||
it('keeps dynamic viewport height as the web default', () => {
|
||||
const css = readSource('src/renderer/src/assets/main.css')
|
||||
|
||||
for (const selector of ['body', '#root', '.app-layout']) {
|
||||
@@ -22,14 +22,29 @@ describe('web viewport shell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses a percentage height chain only for native Electron shells', () => {
|
||||
const css = readSource('src/renderer/src/assets/main.css')
|
||||
const nativeIndex = readSource('src/renderer/index.html')
|
||||
const popoutIndex = readSource('src/renderer/popout.html')
|
||||
const webIndex = readSource('src/renderer/web-index.html')
|
||||
const appSource = readSource('src/renderer/src/App.tsx')
|
||||
|
||||
expect(css).toMatch(
|
||||
/html\.native-shell,\s*html\.native-shell body,\s*html\.native-shell #root,\s*html\.native-shell \.app-layout\s*\{\s*height: 100%;\s*\}/
|
||||
)
|
||||
expect(nativeIndex).toContain('<html class="native-shell">')
|
||||
expect(popoutIndex).toContain('<html class="native-shell">')
|
||||
expect(webIndex).not.toContain('native-shell')
|
||||
expect(appSource).toContain('className="app-layout"')
|
||||
expect(appSource).not.toContain('h-dvh')
|
||||
})
|
||||
|
||||
it('uses dynamic viewport Tailwind utilities for web shell entry points', () => {
|
||||
const source = [
|
||||
readSource('src/renderer/src/App.tsx'),
|
||||
readSource('src/renderer/src/web/main.tsx'),
|
||||
readSource('src/renderer/src/web/WebConnect.tsx')
|
||||
].join('\n')
|
||||
|
||||
expect(source).toContain('h-dvh')
|
||||
expect(source).toContain('min-h-dvh')
|
||||
expect(source).not.toMatch(/\b(?:min-)?h-screen\b/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user