fix(window): restore native fullscreen after a restart (#40540)

Persist windowFullScreen on enter/leave-full-screen (frozen once the window
starts closing) and re-enter fullscreen on reveal after the saved maximize,
skipping background launches. Ignore the spurious unmaximize macOS emits
while a launch-maximized window enters fullscreen so windowMaximized and
windowBounds keep the pre-fullscreen state.

Fixes #40510

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Yohan
2026-11-11 03:29:12 -08:00
committed by GitHub
co-authored by Neil
parent 079b5de1ac
commit 90e5072c8b
6 changed files with 116 additions and 5 deletions
@@ -11,6 +11,7 @@ type MainOwnedUIState =
| '_explorerDisplayRootMigrated'
| 'trayMinimizeNoticeShown'
| 'dashboardPopoutBounds'
| 'windowFullScreen'
| '_expandedWorktreeCardPropertiesDefaulted'
| '_jiraIssueWorktreeCardPropertyDefaulted'
| '_hostWorktreeCardPropertyDefaulted'
+2
View File
@@ -88,6 +88,7 @@ export function createMainWindow(
)
}
const savedMaximized = store?.getUI().windowMaximized ?? false
const savedFullScreen = store?.getUI().windowFullScreen ?? false
// Fill the primary work area on first launch; later launches honor saved bounds.
const defaultBounds = (() => {
try {
@@ -203,6 +204,7 @@ export function createMainWindow(
mainWindow,
revealOnDidFinishLoad: opts?.revealOnDidFinishLoad === true,
savedMaximized,
savedFullScreen,
store
})
installMainWindowWebviewSecurity(mainWindow)
@@ -1,13 +1,14 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const { appMock } = await vi.hoisted(async () => {
const { appMock, launchPolicy } = await vi.hoisted(async () => {
const { EventEmitter } = await import('node:events')
return { appMock: new EventEmitter() }
return { appMock: new EventEmitter(), launchPolicy: { background: false, windowless: true } }
})
vi.mock('electron', () => ({ app: appMock }))
vi.mock('./foreground-activation-policy', () => ({
isWindowlessLaunch: () => true,
isBackgroundLaunch: () => launchPolicy.background,
isWindowlessLaunch: () => launchPolicy.windowless,
showWindowWithoutStealingFocus: vi.fn()
}))
vi.mock('./main-window-visual-lifecycle', () => ({
@@ -21,6 +22,8 @@ import { installMainWindowStateLifecycle } from './main-window-state-lifecycle'
beforeEach(() => {
vi.useFakeTimers()
appMock.removeAllListeners()
launchPolicy.background = false
launchPolicy.windowless = true
})
afterEach(() => vi.useRealTimers())
@@ -41,6 +44,7 @@ it('continues saving bounds after an updater quit veto and freezes them on allow
mainWindow: mainWindow as never,
revealOnDidFinishLoad: false,
savedMaximized: false,
savedFullScreen: false,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Bounds persistence reads only updateUI from this store fixture.
store: { updateUI } as never
})
@@ -62,3 +66,85 @@ it('continues saving bounds after an updater quit veto and freezes them on allow
lifecycle.clearInitialRevealFallbackTimer()
lifecycle.dispose()
})
function installFullScreenFixture(): {
lifecycle: ReturnType<typeof installMainWindowStateLifecycle>
mainWindow: EventEmitter & { setFullScreen: (flag: boolean) => void }
windowState: { maximized: boolean; fullScreen: boolean; maximizedUnderFullScreen: boolean }
persisted: Record<string, unknown>
} {
launchPolicy.windowless = false
const windowState = { maximized: false, fullScreen: false, maximizedUnderFullScreen: false }
const mainWindow = Object.assign(new EventEmitter(), {
webContents: Object.assign(new EventEmitter(), { send: vi.fn(), setZoomLevel: vi.fn() }),
isDestroyed: () => false,
isFullScreen: () => windowState.fullScreen,
getBounds: () => ({ x: 0, y: 0, width: 2560, height: 1440 }),
maximize: () => {
windowState.maximized = true
},
setFullScreen: (flag: boolean) => {
if (flag) {
windowState.maximizedUnderFullScreen = windowState.maximized
}
windowState.fullScreen = flag
}
})
const persisted: Record<string, unknown> = {}
const lifecycle = installMainWindowStateLifecycle({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture implements the window members read by reveal and fullscreen persistence.
mainWindow: mainWindow as never,
revealOnDidFinishLoad: false,
savedMaximized: true,
savedFullScreen: true,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Fullscreen persistence reads only updateUI from this store fixture.
store: {
updateUI: (patch: Record<string, unknown>) => Object.assign(persisted, patch)
} as never
})
return { lifecycle, mainWindow, windowState, persisted }
}
it('restores native fullscreen over the maximized frame and keeps that frame while fullscreen', () => {
const { lifecycle, mainWindow, windowState, persisted } = installFullScreenFixture()
const leaveFullScreen = (): void => {
mainWindow.setFullScreen(false)
mainWindow.emit('leave-full-screen')
}
mainWindow.emit('ready-to-show')
expect(windowState).toEqual({ maximized: true, fullScreen: true, maximizedUnderFullScreen: true })
// macOS emits unmaximize mid-transition, after the fullscreen style mask is set.
mainWindow.emit('unmaximize')
mainWindow.emit('enter-full-screen')
expect(persisted).toEqual({ windowFullScreen: true })
leaveFullScreen()
expect(persisted).toEqual({ windowFullScreen: false })
mainWindow.emit('unmaximize')
expect(persisted).toEqual({
windowFullScreen: false,
windowMaximized: false,
windowBounds: { x: 0, y: 0, width: 2560, height: 1440 }
})
mainWindow.setFullScreen(true)
mainWindow.emit('enter-full-screen')
appMock.emit('before-quit', { defaultPrevented: false })
leaveFullScreen()
expect(persisted).toMatchObject({ windowFullScreen: true })
lifecycle.clearInitialRevealFallbackTimer()
lifecycle.dispose()
})
it('does not restore native fullscreen for background launches', () => {
launchPolicy.background = true
const { lifecycle, mainWindow, windowState } = installFullScreenFixture()
mainWindow.emit('ready-to-show')
expect(windowState).toEqual({
maximized: true,
fullScreen: false,
maximizedUnderFullScreen: false
})
lifecycle.clearInitialRevealFallbackTimer()
lifecycle.dispose()
})
+21 -2
View File
@@ -1,7 +1,11 @@
import { app, type BrowserWindow, type Event } from 'electron'
import type { Store } from '../persistence/loading-store/store'
import { uiZoomFactorFromLevel } from '../../shared/ui-zoom-level'
import { isWindowlessLaunch, showWindowWithoutStealingFocus } from './foreground-activation-policy'
import {
isBackgroundLaunch,
isWindowlessLaunch,
showWindowWithoutStealingFocus
} from './foreground-activation-policy'
import { MIN_HEIGHT, MIN_WIDTH, syncTrafficLightPosition } from './main-window-visual-lifecycle'
export type MainWindowStateLifecycle = {
@@ -16,9 +20,10 @@ export function installMainWindowStateLifecycle(args: {
mainWindow: BrowserWindow
revealOnDidFinishLoad: boolean
savedMaximized: boolean
savedFullScreen: boolean
store: Store | null
}): MainWindowStateLifecycle {
const { mainWindow, revealOnDidFinishLoad, savedMaximized, store } = args
const { mainWindow, revealOnDidFinishLoad, savedMaximized, savedFullScreen, store } = args
mainWindow.webContents.on('dom-ready', () => {
const level = store?.getUI().uiZoomLevel ?? 0
mainWindow.webContents.setZoomLevel(level)
@@ -66,6 +71,10 @@ export function installMainWindowStateLifecycle(args: {
mainWindow.maximize()
}
showWindowWithoutStealingFocus(mainWindow)
// Fullscreen needs a shown window; after maximize so leaving it returns there. Background launches must not take a Space.
if (savedFullScreen && !isBackgroundLaunch()) {
mainWindow.setFullScreen(true)
}
}
mainWindow.on('ready-to-show', revealInitialWindow)
if (revealOnDidFinishLoad === true) {
@@ -129,6 +138,10 @@ export function installMainWindowStateLifecycle(args: {
return
}
mainWindow.webContents.send('window:maximize-changed', false)
// macOS emits unmaximize when a launch-maximized window enters fullscreen; keep the pre-fullscreen state.
if (mainWindow.isFullScreen()) {
return
}
const bounds = mainWindow.getBounds()
// Teardown unmaximize can emit minimum bounds; apply the same persistence guard.
if (bounds.width <= MIN_WIDTH || bounds.height <= MIN_HEIGHT) {
@@ -140,10 +153,16 @@ export function installMainWindowStateLifecycle(args: {
})
mainWindow.on('enter-full-screen', () => {
if (!windowClosing) {
store?.updateUI({ windowFullScreen: true })
}
mainWindow.webContents.send('window:fullscreen-changed', true)
})
mainWindow.on('leave-full-screen', () => {
if (!windowClosing) {
store?.updateUI({ windowFullScreen: false })
}
mainWindow.webContents.send('window:fullscreen-changed', false)
})
+2
View File
@@ -223,6 +223,8 @@ export type PersistedUIState = {
windowBounds?: { x: number; y: number; width: number; height: number } | null
/** Whether the window was maximized when it was last closed. */
windowMaximized?: boolean
/** Whether the window was in native fullscreen when it was last closed. */
windowFullScreen?: boolean
/** Dashboard popout bounds, independent of main-window bounds. */
dashboardPopoutBounds?: { x: number; y: number; width: number; height: number } | null
/** Migrate legacy recent→smart once so later activity-based recent selections survive. */
@@ -156,6 +156,7 @@ export const UI_STATE_GLOBAL_FIELDS = [
'browserDevToolsDock',
'windowBounds',
'windowMaximized',
'windowFullScreen',
'dashboardPopoutBounds',
'starNagBaselineAgents',
'starNagAppVersion',