From 90e5072c8b5416bab4e643b3ef526954873ea69d Mon Sep 17 00:00:00 2001 From: Yohan <8709587+yohangwak@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:29:12 +0900 Subject: [PATCH] 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> --- .../ui-state-schema-parity-checks.ts | 1 + src/main/window/createMainWindow.ts | 2 + .../main-window-state-lifecycle.test.ts | 92 ++++++++++++++++++- .../window/main-window-state-lifecycle.ts | 23 ++++- src/shared/persisted-ui-state-types.ts | 2 + .../wsl-move/wsl-move-ui-dispositions.ts | 1 + 6 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/rpc/methods/client-state/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/client-state/ui-state-schema-parity-checks.ts index 2d0b1607b1c..e57c7df3950 100644 --- a/src/main/runtime/rpc/methods/client-state/ui-state-schema-parity-checks.ts +++ b/src/main/runtime/rpc/methods/client-state/ui-state-schema-parity-checks.ts @@ -11,6 +11,7 @@ type MainOwnedUIState = | '_explorerDisplayRootMigrated' | 'trayMinimizeNoticeShown' | 'dashboardPopoutBounds' + | 'windowFullScreen' | '_expandedWorktreeCardPropertiesDefaulted' | '_jiraIssueWorktreeCardPropertyDefaulted' | '_hostWorktreeCardPropertyDefaulted' diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 7004c6b3431..72101f46410 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -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) diff --git a/src/main/window/main-window-state-lifecycle.test.ts b/src/main/window/main-window-state-lifecycle.test.ts index 3015481033f..5cc44785853 100644 --- a/src/main/window/main-window-state-lifecycle.test.ts +++ b/src/main/window/main-window-state-lifecycle.test.ts @@ -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 + mainWindow: EventEmitter & { setFullScreen: (flag: boolean) => void } + windowState: { maximized: boolean; fullScreen: boolean; maximizedUnderFullScreen: boolean } + persisted: Record +} { + 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 = {} + 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) => 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() +}) diff --git a/src/main/window/main-window-state-lifecycle.ts b/src/main/window/main-window-state-lifecycle.ts index 8f9ffe56509..d772aa78b5b 100644 --- a/src/main/window/main-window-state-lifecycle.ts +++ b/src/main/window/main-window-state-lifecycle.ts @@ -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) }) diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 42289c52ec9..601678adbdf 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -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. */ diff --git a/src/shared/wsl-move/wsl-move-ui-dispositions.ts b/src/shared/wsl-move/wsl-move-ui-dispositions.ts index 67ab5022581..d38b088f223 100644 --- a/src/shared/wsl-move/wsl-move-ui-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-ui-dispositions.ts @@ -156,6 +156,7 @@ export const UI_STATE_GLOBAL_FIELDS = [ 'browserDevToolsDock', 'windowBounds', 'windowMaximized', + 'windowFullScreen', 'dashboardPopoutBounds', 'starNagBaselineAgents', 'starNagAppVersion',