From 154d66f4ab264c1324fed889a2fe3374dfd291af Mon Sep 17 00:00:00 2001 From: Keeley Hammond Date: Mon, 6 Jul 2026 13:01:19 -0700 Subject: [PATCH] feat: add save/restore window state API (#51510) * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * test: support for multimonitor tests (#47911) * test: support for multimonitor tests * fix: update yarn.lock file * test: support any resolution for new displays * test: support display positioning * docs: multi-monitor tests * test: remove dummy test * feat: enforce unique window names across BaseWindow and BrowserWindow (#47764) * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * feat: enforce unique window names across BaseWindow and BrowserWindow * docs: update docs for name property * fix: linter issue with symbol --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * feat: clear and restore window state (#47781) * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * feat: restore window state * feat: flush display modes on show * refactor: move utility functions to common area * feat: clear window state * fix: wait for the prefs to update * test: clearWindowState extra test * test: refine clear window state tests * test: single monitor restore window tests chore: rebase on gsoc-2025 * refactor: refine clearWindowState test * fix: revert default_app back to original * docs: add comment linking AdjustBoundsToBeVisibleOnDisplay to Chromium code * fix: add correct permalink * refactor: ci friendly * fix: disable windowStatePersistence when no display * refactor: use reference instead pointer * fix: skip window state persistence for invalid/fake displays * refactor: better flag placement * test: add test to verify window state is not saved when no display * fix: restore display mode inside show() * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * test: clear sharedUserPath before and test * refactor: hasInvalidDisplay function * debug: add display info logging for CI * fix: do not save/restore when window is 0x0 * test: support for multimonitor tests (#47911) * test: support for multimonitor tests * fix: update yarn.lock file * test: support any resolution for new displays * test: support display positioning * docs: multi-monitor tests * test: remove dummy test * feat: enforce unique window names across BaseWindow and BrowserWindow (#47764) * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * feat: enforce unique window names across BaseWindow and BrowserWindow * docs: update docs for name property * fix: linter issue with symbol --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * test: remove invalid display test --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao Co-authored-by: Keeley Hammond * test: multi monitor tests for save/restore window state (#48048) * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * feat: restore window state * feat: flush display modes on show * refactor: move utility functions to common area * feat: clear window state * fix: wait for the prefs to update * test: clearWindowState extra test * test: refine clear window state tests * test: single monitor restore window tests chore: rebase on gsoc-2025 * refactor: refine clearWindowState test * fix: revert default_app back to original * docs: add comment linking AdjustBoundsToBeVisibleOnDisplay to Chromium code * fix: add correct permalink * refactor: ci friendly * fix: disable windowStatePersistence when no display * refactor: use reference instead pointer * fix: skip window state persistence for invalid/fake displays * refactor: better flag placement * test: add test to verify window state is not saved when no display * fix: restore display mode inside show() * feat: support for multimonitor tests * fix: update yarn.lock file * feat: support any resolution for new displays * feat: support display positioning * docs: multi-monitor tests * test: remove dummy test * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * test: clear sharedUserPath before and test * refactor: hasInvalidDisplay function * debug: add display info logging for CI * fix: do not save/restore when window is 0x0 * test: support for multimonitor tests (#47911) * test: support for multimonitor tests * fix: update yarn.lock file * test: support any resolution for new displays * test: support display positioning * docs: multi-monitor tests * test: remove dummy test * fix: native-addon forceCleanup * docs: add forceCleanup description * test: add two basic multi-monitor tests * fix: find the closest display for non-overlapping saved bounds * test: windowStatePersistence multi-monitor tests * docs: add note on display APIs in CI * fix: remove duplicate destroy registration * feat: enforce unique window names across BaseWindow and BrowserWindow (#47764) * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * feat: enforce unique window names across BaseWindow and BrowserWindow * docs: update docs for name property * fix: linter issue with symbol --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * docs: remove inaccurate comment * fix: move expect blocks outside beforeEach * test: exclude macOS-x64 for now * test: remove invalid display test * test: remove invalid display test * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * test: support for multimonitor tests (#47911) * test: support for multimonitor tests * fix: update yarn.lock file * test: support any resolution for new displays * test: support display positioning * docs: multi-monitor tests * test: remove dummy test * feat: enforce unique window names across BaseWindow and BrowserWindow (#47764) * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * feat: enforce unique window names across BaseWindow and BrowserWindow * docs: update docs for name property * fix: linter issue with symbol --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * feat: clear and restore window state (#47781) * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * feat: restore window state * feat: flush display modes on show * refactor: move utility functions to common area * feat: clear window state * fix: wait for the prefs to update * test: clearWindowState extra test * test: refine clear window state tests * test: single monitor restore window tests chore: rebase on gsoc-2025 * refactor: refine clearWindowState test * fix: revert default_app back to original * docs: add comment linking AdjustBoundsToBeVisibleOnDisplay to Chromium code * fix: add correct permalink * refactor: ci friendly * fix: disable windowStatePersistence when no display * refactor: use reference instead pointer * fix: skip window state persistence for invalid/fake displays * refactor: better flag placement * test: add test to verify window state is not saved when no display * fix: restore display mode inside show() * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * test: clear sharedUserPath before and test * refactor: hasInvalidDisplay function * debug: add display info logging for CI * fix: do not save/restore when window is 0x0 * test: support for multimonitor tests (#47911) * test: support for multimonitor tests * fix: update yarn.lock file * test: support any resolution for new displays * test: support display positioning * docs: multi-monitor tests * test: remove dummy test * feat: enforce unique window names across BaseWindow and BrowserWindow (#47764) * feat: save window state (#47425) * feat: save/restore window state * cleanup * remove constructor option * refactor: apply suggestions from code review Co-authored-by: Charles Kerr * refactor: forward declare prefservice * refactor: remove constructor option * refactor: save window state on move/resize instead of moved/resized * feat: resave window state after construction * test: add basic window save tests * test: add work area tests * test: asynchronous batching behavior * docs: add windowStateRestoreOptions to BaseWindowConstructorOptions * chore: move includes to main block * Update spec/api-browser-window-spec.ts Co-authored-by: David Sanders * docs: update docs/api/structures/base-window-options.md Co-authored-by: Erick Zhao * fix: preserve original bounds during window state save in special modes * feat: save kiosk state in window preferences * chore: remove ts-expect-error * test: check hasCapturableScreen before running tests * test: remove multimonitor tests * test: add missing hasCapturableScreen checks before tests * docs: add blurb on saving mechanism * feat: add debounce window of 200ms to saveWindowState * docs: remove blurb until finalized * style: convert constants from snake_case to camelCase * refactor: initialize prefs_ only if window state is configured to be saved/restored * refactor: rename window states key * refactor: store in application-level Local State instead of browser context * refactor: switch to more accurate function names * fix: add dcheck for browser_process * fix: flush window state to avoid race condition * refactor: change stateId to name * refactor: change windowStateRestoreOptions to windowStatePersistence * Update docs/api/structures/base-window-options.md Co-authored-by: David Sanders * fix: add warning when window state persistence enabled without window name * docs: lowercase capital B for consistency --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * feat: enforce unique window names across BaseWindow and BrowserWindow * docs: update docs for name property * fix: linter issue with symbol --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao * test: remove invalid display test --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao Co-authored-by: Keeley Hammond --------- Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao Co-authored-by: Keeley Hammond * feat: add 'restored-persisted-state' event to BaseWindow (#50516) * docs: add note on display APIs in CI * docs: remove inaccurate comment * test: set show to true * docs: restored-window-state event * feat: add 'restored-window-state' event to BaseWindow * test: add tests for 'restored-window-state' event emission * test: move createWindowAndSave * docs: differentiate between name and title * test: add checks for virtual display creation * docs: tutorial for windowStatePersistence * fix: ensure only single display before multi monitor tests * docs: better docs for virtual display addon * docs: better docs for virtual display addon * refactor: rename event restored-window-state to restored-persisted-state * refactor: rename BaseWindow.clearWindowState to clearPersistedState * docs: improve docs for name property and fix typos * build: run yarn install * fix: update base::Value::Dict -> base::DictValue * fix: add install script to virtual-display native addon The virtual-display fixture was missing an "install" script, so node-gyp never compiled it during `yarn install`. This caused "Cannot find module '../build/Release/virtual_display.node'" in CI. Also removed unused dependencies (bindings, @types/jest, node-gyp) to match the pattern of other native addon fixtures. Co-Authored-By: Claude --------- Co-authored-by: Nilay Arya Co-authored-by: Claude * chore: fixup yarn.lock, lint * chore: fix build errors after rebase onto main Remove stale `#include "shell/browser/native_window_features.h"` (header was deleted in #51160) and add the required `animate` parameter to the `SetBounds` call in `RestoreBounds` (signature changed on main). * fix: rewrite virtual-display addon in Objective-C, removing Swift dependency The CI runner's Xcode 16.4 has a broken Swift toolchain where swift-frontend cannot load lib_CompilerSwiftIDEUtils.dylib. Since the addon only uses CoreGraphics private C/ObjC APIs (CGVirtualDisplay, CGVirtualDisplayDescriptor, etc.), rewrite the implementation entirely in Objective-C to eliminate the Swift compilation step. Co-Authored-By: Claude * chore: remove nut-js dependent tests * test: fix macos display tests * Update docs/api/base-window.md Co-authored-by: Sam Maddock * Update docs/tutorial/window-state-persistence.md Co-authored-by: Erick Zhao * Update docs/tutorial/window-state-persistence.md Co-authored-by: Erick Zhao * fix: defer is_being_restored reset and persisted-state-restored to display-mode callback Resetting is_being_restored_ synchronously at the end of RestoreWindowState let the deferred display-mode callback (kiosk/fullscreen/maximize) fire from Show() with the flag already false, so the resize/move events it produced scheduled DebouncedSaveWindowState and persisted transitional bounds over the user's saved bounds. Also rename the BaseWindow event to persisted-state-restored consistently across the C++ emit, tutorial, and spec, and move the 25s busy-loop spec into a spawned Electron fixture so the spec runner main thread isn't blocked. * test: bump main-thread-busy spec timeout to 60s Spawned Electron fixture sleeps 25s plus startup, exceeding mocha's 30s default. * fix: emit persisted-state-restored synchronously, not in deferred callback The previous fix moved NotifyWindowStateRestored() into the deferred display-mode callback to keep it ordered after the kiosk/fullscreen/ maximize change. But that callback only fires from Show() via FlushPendingDisplayMode(), so windows constructed with show: false (and restore_display_mode_ true, which is the default) never emitted the event - including the spec coverage. Restore the synchronous emit and keep the is_being_restored_ deferral, since only the flag reset needs to straddle the deferred resize/move events that schedule DebouncedSaveWindowState. * fix: tie window-state restore guard to display-mode transition lifecycle The is_being_restored_ guard was reset on the deferred display-mode callback invocation rather than the actual restoration lifecycle, which caused three defects: 1. A window created with show:false and destroyed before show() left the guard stuck true, so the close-time FlushWindowState save no-op'd and state changes made while hidden were dropped. 2. showInactive() never flushed the deferred display mode, so a persisted hidden window shown inactive did not re-apply saved fullscreen/ maximize/kiosk and left the guard stuck true. 3. On macOS the guard was reset synchronously after the async SetFullScreen/Maximize/SetKiosk calls, so a flush during the transition persisted transitional bounds over the saved state. Reset the guard from the NotifyWindowEnterFullScreen/NotifyWindowMaximize observers once the transition completes, drain a never-run callback in FlushWindowState, flush the pending display mode from ShowInactive on both backends, and skip saves while a fullscreen transition is in flight. --------- Co-authored-by: Nilay Arya <84241885+nilayarya@users.noreply.github.com> Co-authored-by: Charles Kerr Co-authored-by: David Sanders Co-authored-by: Erick Zhao Co-authored-by: Nilay Arya Co-authored-by: Claude Co-authored-by: George Xu <33054982+georgexu99@users.noreply.github.com> Co-authored-by: Sam Maddock Co-authored-by: George Xu --- docs/README.md | 3 +- docs/api/base-window.md | 17 + docs/api/structures/base-window-options.md | 4 +- .../structures/window-state-persistence.md | 4 + docs/development/multi-monitor-testing.md | 111 ++ docs/development/testing.md | 8 + docs/tutorial/window-state-persistence.md | 102 + filenames.auto.gni | 1 + lib/browser/api/browser-window.ts | 2 + shell/browser/api/electron_api_base_window.cc | 65 +- shell/browser/api/electron_api_base_window.h | 8 + .../api/electron_api_browser_window.cc | 7 + shell/browser/browser_process_impl.cc | 3 + shell/browser/native_window.cc | 365 +++- shell/browser/native_window.h | 65 +- shell/browser/native_window_mac.mm | 4 + shell/browser/native_window_observer.h | 2 + shell/browser/native_window_views.cc | 4 + shell/common/electron_constants.h | 17 + shell/common/options_switches.h | 13 + spec/api-browser-window-spec.ts | 1647 +++++++++++++++++ .../api/window-state-save/close-save/index.js | 27 + .../fullscreen-save/index.js | 28 + .../api/window-state-save/kiosk-save/index.js | 28 + .../main-thread-busy/index.js | 41 + .../window-state-save/maximize-save/index.js | 28 + .../window-state-save/minimize-save/index.js | 28 + .../api/window-state-save/move-save/index.js | 23 + .../window-state-save/resize-save/index.js | 23 + .../window-state-save/schema-check/index.js | 23 + .../work-area-primary/index.js | 41 + .../native-addon/virtual-display/binding.gyp | 34 + .../include/VirtualDisplayBridge.h | 121 ++ .../virtual-display/lib/virtual-display.js | 7 + .../native-addon/virtual-display/package.json | 15 + .../src/VirtualDisplayBridge.m | 145 ++ .../native-addon/virtual-display/src/addon.mm | 197 ++ spec/package.json | 1 + yarn.lock | 18 + 39 files changed, 3272 insertions(+), 8 deletions(-) create mode 100644 docs/api/structures/window-state-persistence.md create mode 100644 docs/development/multi-monitor-testing.md create mode 100644 docs/tutorial/window-state-persistence.md create mode 100644 spec/fixtures/api/window-state-save/close-save/index.js create mode 100644 spec/fixtures/api/window-state-save/fullscreen-save/index.js create mode 100644 spec/fixtures/api/window-state-save/kiosk-save/index.js create mode 100644 spec/fixtures/api/window-state-save/main-thread-busy/index.js create mode 100644 spec/fixtures/api/window-state-save/maximize-save/index.js create mode 100644 spec/fixtures/api/window-state-save/minimize-save/index.js create mode 100644 spec/fixtures/api/window-state-save/move-save/index.js create mode 100644 spec/fixtures/api/window-state-save/resize-save/index.js create mode 100644 spec/fixtures/api/window-state-save/schema-check/index.js create mode 100644 spec/fixtures/api/window-state-save/work-area-primary/index.js create mode 100644 spec/fixtures/native-addon/virtual-display/binding.gyp create mode 100644 spec/fixtures/native-addon/virtual-display/include/VirtualDisplayBridge.h create mode 100644 spec/fixtures/native-addon/virtual-display/lib/virtual-display.js create mode 100644 spec/fixtures/native-addon/virtual-display/package.json create mode 100644 spec/fixtures/native-addon/virtual-display/src/VirtualDisplayBridge.m create mode 100644 spec/fixtures/native-addon/virtual-display/src/addon.mm diff --git a/docs/README.md b/docs/README.md index 02d5d1331c..3af381dc56 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,10 +37,11 @@ an issue: * [Represented File for macOS BrowserWindows](tutorial/represented-file.md) * [Native File Drag & Drop](tutorial/native-file-drag-drop.md) * [Navigation History](tutorial/navigation-history.md) + * [Window State Persistence](tutorial/window-state-persistence.md) * [Offscreen Rendering](tutorial/offscreen-rendering.md) * [Dark Mode](tutorial/dark-mode.md) * [Web embeds in Electron](tutorial/web-embeds.md) -* [Boilerplates and CLIs](tutorial/boilerplates-and-clis.md) + * [Boilerplates and CLIs](tutorial/boilerplates-and-clis.md) * [Boilerplate vs CLI](tutorial/boilerplates-and-clis.md#boilerplate-vs-cli) * [Electron Forge](tutorial/boilerplates-and-clis.md#electron-forge) * [electron-builder](tutorial/boilerplates-and-clis.md#electron-builder) diff --git a/docs/api/base-window.md b/docs/api/base-window.md index d8ac5cea6b..e7ba1de9c6 100644 --- a/docs/api/base-window.md +++ b/docs/api/base-window.md @@ -377,6 +377,15 @@ Calling `event.preventDefault()` will prevent the menu from being displayed. To convert `point` to DIP, use [`screen.screenToDipPoint(point)`](./screen.md#screenscreentodippointpoint-windows-linux). +#### Event: 'persisted-state-restored' + +Emitted after the persisted window state has been restored. + +Window state includes the window bounds (x, y, height, width) and display mode (maximized, fullscreen, kiosk). + +> [!NOTE] +> This event is only emitted when [windowStatePersistence](structures/window-state-persistence.md) is enabled in [BaseWindowConstructorOptions](structures/base-window-options.md) or in [BrowserWindowConstructorOptions](structures/browser-window-options.md). + ### Static Methods The `BaseWindow` class has the following static methods: @@ -395,6 +404,14 @@ Returns `BaseWindow | null` - The window that is focused in this application, ot Returns `BaseWindow | null` - The window with the given `id`. +#### `BaseWindow.clearPersistedState(name)` + +* `name` string - The window `name` to clear state for (see [BaseWindowConstructorOptions](structures/base-window-options.md)). + +Clears the saved state for a window with the given name. This removes all persisted window bounds, display mode, and work area information that was previously saved when `windowStatePersistence` was enabled. + +If the window `name` is empty or the window state doesn't exist, the method will log a warning. + ### Instance Properties Objects created with `new BaseWindow` have the following properties: diff --git a/docs/api/structures/base-window-options.md b/docs/api/structures/base-window-options.md index 95e55748e3..8e848cb614 100644 --- a/docs/api/structures/base-window-options.md +++ b/docs/api/structures/base-window-options.md @@ -42,6 +42,8 @@ Default is `false`. * `hiddenInMissionControl` boolean (optional) _macOS_ - Whether window should be hidden when the user toggles into mission control. * `kiosk` boolean (optional) - Whether the window is in kiosk mode. Default is `false`. +* `name` string (optional) - A unique identifier for the window, used internally by Electron to enable features such as state persistence. Each window must have a distinct name. It can only be reused after the corresponding window has been destroyed. An error is thrown if the name is already in use. This is not the visible title shown to users on the title bar. +* `windowStatePersistence` ([WindowStatePersistence](window-state-persistence.md) | boolean) (optional) - Configures or enables the persistence of window state (position, size, maximized state, etc.) across application restarts. Has no effect if window `name` is not provided. Automatically disabled when there is no available display. _Experimental_ * `title` string (optional) - Default window title. Default is `"Electron"`. If the HTML tag `` is defined in the HTML file loaded by `loadURL()`, this property will be ignored. * `icon` ([NativeImage](../native-image.md) | string) (optional) - The window icon. On Windows it is recommended to use `ICO` icons to get best visual effects, you can also @@ -94,7 +96,7 @@ title bar and a full size content window, the traffic light buttons will display when being hovered over in the top left of the window. **Note:** This option is currently experimental. -* `titleBarOverlay` Object | Boolean (optional) - When using a frameless window in conjunction with `win.setWindowButtonVisibility(true)` on macOS or using a `titleBarStyle` so that the standard window controls ("traffic lights" on macOS) are visible, this property enables the Window Controls Overlay [JavaScript APIs][overlay-javascript-apis] and [CSS Environment Variables][overlay-css-env-vars]. Specifying `true` will result in an overlay with default system colors. Default is `false`. +* `titleBarOverlay` Object | boolean (optional) - When using a frameless window in conjunction with `win.setWindowButtonVisibility(true)` on macOS or using a `titleBarStyle` so that the standard window controls ("traffic lights" on macOS) are visible, this property enables the Window Controls Overlay [JavaScript APIs][overlay-javascript-apis] and [CSS Environment Variables][overlay-css-env-vars]. Specifying `true` will result in an overlay with default system colors. Default is `false`. * `color` String (optional) _Windows_ _Linux_ - The CSS color of the Window Controls Overlay when enabled. Default is the system color. * `symbolColor` String (optional) _Windows_ _Linux_ - The CSS color of the symbols on the Window Controls Overlay when enabled. Default is the system color. * `height` Integer (optional) - The height of the title bar and Window Controls Overlay in pixels. Default is system height. diff --git a/docs/api/structures/window-state-persistence.md b/docs/api/structures/window-state-persistence.md new file mode 100644 index 0000000000..efb02bb56d --- /dev/null +++ b/docs/api/structures/window-state-persistence.md @@ -0,0 +1,4 @@ +# WindowStatePersistence Object + +* `bounds` boolean (optional) - Whether to persist window position and size across application restarts. Defaults to `true` if not specified. +* `displayMode` boolean (optional) - Whether to persist display modes (fullscreen, kiosk, maximized, etc.) across application restarts. Defaults to `true` if not specified. diff --git a/docs/development/multi-monitor-testing.md b/docs/development/multi-monitor-testing.md new file mode 100644 index 0000000000..900c8170d3 --- /dev/null +++ b/docs/development/multi-monitor-testing.md @@ -0,0 +1,111 @@ +# Multi-Monitor Testing + +The `virtualDisplay` addon leverages macOS CoreGraphics APIs to create virtual displays, allowing you to write and run multi-monitor tests without the need for physical monitors. Due to macOS CoreGraphics quirks, reading the entire guide once before writing tests is recommended. + +## Methods + +#### `virtualDisplay.create([options])` + +Creates a virtual display and returns a display ID. + +```js @ts-nocheck +const virtualDisplay = require('@electron-ci/virtual-display') +// Default: 1920×1080 at origin (0, 0) +const displayId = virtualDisplay.create() +``` + +```js @ts-nocheck +const virtualDisplay = require('@electron-ci/virtual-display') +// Custom options (all parameters optional and have default values) +const displayId = virtualDisplay.create({ + width: 2560, // Display width in pixels + height: 1440, // Display height in pixels + x: 1920, // X position (top-left corner) + y: 0 // Y position (top-left corner) +}) +``` + +**Returns:** `number` - Unique display ID used to identify the display. Returns `0` on failure to create display. + +> [!NOTE] +> It is recommended to call [`virtualDisplay.forceCleanup()`](#virtualdisplayforcecleanup) before every test to prevent display creation from failing in that test. macOS CoreGraphics maintains an internal display ID allocation pool that can become corrupted when virtual displays are created and destroyed rapidly during testing. Without proper cleanup, subsequent display creation may fail with inconsistent display IDs, resulting in test flakiness. + +#### `virtualDisplay.forceCleanup()` + +Performs a complete cleanup of all virtual displays and resets the macOS CoreGraphics display system. + +```js @ts-nocheck +beforeEach(() => { + virtualDisplay.forceCleanup() +}) +``` + +#### `virtualDisplay.destroy(displayId)` + +Removes the virtual display. + +```js @ts-nocheck +virtualDisplay.destroy(displayId) +``` + +> [!NOTE] +> Always destroy virtual displays after use to prevent corrupting the macOS CoreGraphics display pool and affecting subsequent tests. + +## Recommended usage pattern + +```js @ts-nocheck +describe('multi-monitor tests', () => { + const virtualDisplay = require('@electron-ci/virtual-display') + beforeEach(() => { + virtualDisplay.forceCleanup() + }) + + it('should handle multiple displays', () => { + const display1 = virtualDisplay.create({ width: 1920, height: 1080, x: 0, y: 0 }) + const display2 = virtualDisplay.create({ width: 2560, height: 1440, x: 1920, y: 0 }) + // Your test logic here + virtualDisplay.destroy(display1) + virtualDisplay.destroy(display2) + }) +}) +``` + +## Display Constraints + +### Size Limits + +Virtual displays are constrained to 720×720 pixels minimum and 8192×8192 pixels maximum. Actual limits may vary depending on your Mac's graphics capabilities, so sizes outside this range (like 9000×6000) may fail on some systems. + +```js @ts-nocheck +// Safe sizes for testing +virtualDisplay.create({ width: 1920, height: 1080 }) // Full HD +virtualDisplay.create({ width: 3840, height: 2160 }) // 4K +``` + +### Positioning Behavior + +macOS maintains a contiguous desktop space by automatically adjusting display positions if there are any overlaps or gaps. In case of either, the placement of the new origin is as close as possible to the requested location, without overlapping or leaving a gap between displays. + +**Overlap:** + +```js @ts-nocheck +// Requested positions +const display1 = virtualDisplay.create({ x: 0, y: 0, width: 1920, height: 1080 }) +const display2 = virtualDisplay.create({ x: 500, y: 0, width: 1920, height: 1080 }) + +// macOS automatically repositions display2 to x: 1920 to prevent overlap +const actualBounds = screen.getAllDisplays().map(d => d.bounds) +// Result: [{ x: 0, y: 0, width: 1920, height: 1080 }, { x: 1920, y: 0, width: 1920, height: 1080 }] +``` + +**Gap:** + +```js @ts-nocheck +// Requested: gap between displays +const display1 = virtualDisplay.create({ width: 1920, height: 1080, x: 0, y: 0 }) +const display2 = virtualDisplay.create({ width: 1920, height: 1080, x: 2000, y: 0 }) +// macOS snaps display2 to x: 1920 (eliminates 80px gap) +``` + +> [!NOTE] +> Always verify actual positions with `screen.getAllDisplays()` after creation, as macOS may adjust coordinates from the set values. diff --git a/docs/development/testing.md b/docs/development/testing.md index 2c99dbd122..d1bc597718 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -95,3 +95,11 @@ To configure display scaling: 1. Push the Windows key and search for _Display settings_. 2. Under _Scale and layout_, make sure that the device is set to 100%. + +## Multi-Monitor Tests + +Some Electron APIs require testing across multiple displays, such as screen detection, window positioning, and display-related events. For contributors working on these features, the `virtualDisplay` native addon enables you to create and position virtual displays programmatically, making it possible to test multi-monitor scenarios without any physical hardware. + +For detailed information on using virtual displays in your tests, see [Multi-Monitor Testing](multi-monitor-testing.md). + +**Platform support:** macOS only diff --git a/docs/tutorial/window-state-persistence.md b/docs/tutorial/window-state-persistence.md new file mode 100644 index 0000000000..87e8ea6218 --- /dev/null +++ b/docs/tutorial/window-state-persistence.md @@ -0,0 +1,102 @@ +# Window State Persistence + +## Overview + +Window State Persistence allows your Electron application to automatically save and restore a window's position, size, and display modes (such as maximized or fullscreen states) across application restarts. + +This feature is particularly useful for applications where users frequently resize, move, or maximize windows and expect them to remain in the same state when reopening the app. + +## Usage + +### Basic usage + +To enable Window State Persistence, set `windowStatePersistence: true` in your window constructor options and provide a unique `name` for the window. + +```js +const { app, BrowserWindow } = require('electron') + +function createWindow () { + const win = new BrowserWindow({ + name: 'main-window', + width: 800, + height: 600, + windowStatePersistence: true + }) + + win.loadFile('index.html') +} + +app.whenReady().then(createWindow) +``` + +With this configuration, Electron will automatically: + +1. Restore the window's position, size, and display mode when created (if a previous state exists) +2. Save the window state whenever it changes (position, size, or display mode). +3. Emit a `persisted-state-restored` event after successfully restoring state. +4. Adapt restored window state to multi-monitor setups and display changes automatically. + +> [!NOTE] +> Window State Persistence requires that the window has a unique `name` property set in its constructor options. This name serves as the identifier for storing and retrieving the window's saved state. + +### Selective persistence + +You can control which aspects of the window state are persisted by passing an object with specific options: + +```js +const { app, BrowserWindow } = require('electron') + +function createWindow () { + const win = new BrowserWindow({ + name: 'main-window', + width: 800, + height: 600, + windowStatePersistence: { + bounds: true, // Save position and size (default: true) + displayMode: false // Don't save maximized/fullscreen/kiosk state (default: true) + } + }) + + win.loadFile('index.html') +} + +app.whenReady().then(createWindow) +``` + +In this example, the window will remember its position and size but will always start in normal mode, even if it was maximized or fullscreened when last closed. + +### Clearing persisted state + +You can programmatically clear the saved state for a specific window using the static `clearPersistedState` method: + +```js +const { BrowserWindow } = require('electron') + +// Clear saved state for a specific window +BrowserWindow.clearPersistedState('main-window') + +// Now when you create a window with this name, +// it will use the default constructor options +const win = new BrowserWindow({ + name: 'main-window', + width: 800, + height: 600, + windowStatePersistence: true +}) +``` + +## API reference + +The Window State Persistence APIs are available on both `BaseWindow` and `BrowserWindow` (since `BrowserWindow` extends `BaseWindow`) and work identically. + +For complete API documentation, see: + +- [`windowStatePersistence` in BaseWindowConstructorOptions][base-window-options] +- [`WindowStatePersistence` object structure][window-state-persistence-structure] +- [`BaseWindow.clearPersistedState()`][clear-persisted-state] +- [`persisted-state-restored` event][restored-event] + +[base-window-options]: ../api/structures/base-window-options.md +[window-state-persistence-structure]: ../api/structures/window-state-persistence.md +[clear-persisted-state]: ../api/base-window.md#basewindowclearpersistedstatename +[restored-event]: ../api/base-window.md#event-persisted-state-restored diff --git a/filenames.auto.gni b/filenames.auto.gni index 0e793f43a0..c67b12867f 100644 --- a/filenames.auto.gni +++ b/filenames.auto.gni @@ -176,6 +176,7 @@ auto_filenames = { "docs/api/structures/webauthn-account.md", "docs/api/structures/window-open-handler-response.md", "docs/api/structures/window-session-end-event.md", + "docs/api/structures/window-state-persistence.md", ] node_header_sources = [ diff --git a/lib/browser/api/browser-window.ts b/lib/browser/api/browser-window.ts index b4d2c15e9c..2aab91e7b6 100644 --- a/lib/browser/api/browser-window.ts +++ b/lib/browser/api/browser-window.ts @@ -115,6 +115,8 @@ BrowserWindow.getAllWindows = () => { return BaseWindow.getAllWindows().filter(isBrowserWindow) as any[] as BWT[]; }; +BrowserWindow.clearPersistedState = BaseWindow.clearPersistedState; + BrowserWindow.getFocusedWindow = () => { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed() && window.webContents && !window.webContents.isDestroyed()) { diff --git a/shell/browser/api/electron_api_base_window.cc b/shell/browser/api/electron_api_base_window.cc index 4f46e64dad..ec6b3787db 100644 --- a/shell/browser/api/electron_api_base_window.cc +++ b/shell/browser/api/electron_api_base_window.cc @@ -10,15 +10,20 @@ #include <vector> #include "base/task/single_thread_task_runner.h" +#include "components/prefs/scoped_user_pref_update.h" #include "content/public/common/color_parser.h" #include "electron/buildflags/buildflags.h" #include "gin/dictionary.h" #include "shell/browser/api/electron_api_menu.h" #include "shell/browser/api/electron_api_view.h" #include "shell/browser/api/electron_api_web_contents.h" +#include "shell/browser/browser_process_impl.h" +#include "shell/browser/electron_browser_main_parts.h" #include "shell/browser/javascript_environment.h" #include "shell/browser/native_window.h" +#include "shell/browser/window_list.h" #include "shell/common/color_util.h" +#include "shell/common/electron_constants.h" #include "shell/common/gin_converters/callback_converter.h" #include "shell/common/gin_converters/file_path_converter.h" #include "shell/common/gin_converters/gfx_converter.h" @@ -170,7 +175,7 @@ void BaseWindow::OnWindowClosed() { // We can not call Destroy here because we need to call Emit first, but we // also do not want any method to be used, so just mark as destroyed here. MarkDestroyed(); - + window_->FlushWindowState(); Emit("closed"); parent_window_.Reset(); @@ -261,6 +266,7 @@ void BaseWindow::OnWindowWillResize(const gfx::Rect& new_bounds, } void BaseWindow::OnWindowResize() { + window_->DebouncedSaveWindowState(); Emit("resize"); } @@ -276,6 +282,7 @@ void BaseWindow::OnWindowWillMove(const gfx::Rect& new_bounds, } void BaseWindow::OnWindowMove() { + window_->DebouncedSaveWindowState(); Emit("move"); } @@ -344,6 +351,10 @@ void BaseWindow::OnSystemContextMenu(int x, int y, bool* prevent_default) { } } +void BaseWindow::OnWindowStateRestored() { + EmitEventSoon("persisted-state-restored"); +} + #if BUILDFLAG(IS_WIN) void BaseWindow::OnWindowMessage(UINT message, WPARAM w_param, LPARAM l_param) { if (IsWindowMessageHooked(message)) { @@ -1152,14 +1163,64 @@ void BaseWindow::SetTitleBarOverlay(const gin_helper::Dictionary& options, } #endif +// static +void BaseWindow::ClearPersistedState(const std::string& window_name) { + if (window_name.empty()) { + LOG(WARNING) << "Cannot clear persisted window state: window name is empty"; + return; + } + + if (auto* browser_process = + electron::ElectronBrowserMainParts::Get()->browser_process()) { + DCHECK(browser_process); + if (auto* prefs = browser_process->local_state()) { + ScopedDictPrefUpdate update(prefs, electron::kWindowStates); + + if (!update->Remove(window_name)) { + LOG(WARNING) << "Window state '" << window_name + << "' not found, nothing to clear"; + } + } + } +} + // static gin_helper::WrappableBase* BaseWindow::New(gin::Arguments* const args) { auto options = gin_helper::Dictionary::CreateEmpty(args->isolate()); args->GetNext(&options); + std::string error_message; + if (!IsWindowNameValid(options, &error_message)) { + // Window name is already in use throw an error and do not create the window + args->ThrowTypeError(error_message); + return nullptr; + } + return new BaseWindow(args, options); } +// static +bool BaseWindow::IsWindowNameValid(const gin_helper::Dictionary& options, + std::string* error_message) { + std::string window_name; + if (options.Get(options::kName, &window_name) && !window_name.empty()) { + // Check if window name is already in use by another window + // Window names must be unique for state persistence to work correctly + const auto& windows = electron::WindowList::GetWindows(); + bool name_in_use = std::any_of(windows.begin(), windows.end(), + [&window_name](const auto* const window) { + return window->GetName() == window_name; + }); + + if (name_in_use) { + *error_message = "Window name '" + window_name + + "' is already in use. Window names must be unique."; + return false; + } + } + return true; +} + // static void BaseWindow::BuildPrototype(v8::Isolate* isolate, v8::Local<v8::FunctionTemplate> prototype) { @@ -1351,6 +1412,8 @@ void Initialize(v8::Local<v8::Object> exports, .ToLocalChecked()); constructor.SetMethod("fromId", &BaseWindow::FromWeakMapID); constructor.SetMethod("getAllWindows", &BaseWindow::GetAll); + constructor.SetMethod("clearPersistedState", + &BaseWindow::ClearPersistedState); gin_helper::Dictionary dict(isolate, exports); dict.Set("BaseWindow", constructor); diff --git a/shell/browser/api/electron_api_base_window.h b/shell/browser/api/electron_api_base_window.h index b62d812781..449b62bc63 100644 --- a/shell/browser/api/electron_api_base_window.h +++ b/shell/browser/api/electron_api_base_window.h @@ -46,6 +46,13 @@ class BaseWindow : public gin_helper::TrackableObject<BaseWindow>, static void BuildPrototype(v8::Isolate* isolate, v8::Local<v8::FunctionTemplate> prototype); + // Clears window state from the Local State JSON file in + // app.getPath('userData') via PrefService. + static void ClearPersistedState(const std::string& window_name); + + static bool IsWindowNameValid(const gin_helper::Dictionary& options, + std::string* error_message); + const NativeWindow* window() const { return window_.get(); } NativeWindow* window() { return window_.get(); } @@ -97,6 +104,7 @@ class BaseWindow : public gin_helper::TrackableObject<BaseWindow>, const base::DictValue& details) override; void OnNewWindowForTab() override; void OnSystemContextMenu(int x, int y, bool* prevent_default) override; + void OnWindowStateRestored() override; #if BUILDFLAG(IS_WIN) void OnWindowMessage(UINT message, WPARAM w_param, LPARAM l_param) override; #endif diff --git a/shell/browser/api/electron_api_browser_window.cc b/shell/browser/api/electron_api_browser_window.cc index f9b2d05b93..abe7a3cb51 100644 --- a/shell/browser/api/electron_api_browser_window.cc +++ b/shell/browser/api/electron_api_browser_window.cc @@ -323,6 +323,13 @@ gin_helper::WrappableBase* BrowserWindow::New(gin_helper::ErrorThrower thrower, options = gin::Dictionary::CreateEmpty(args->isolate()); } + std::string error_message; + if (!IsWindowNameValid(options, &error_message)) { + // Window name is already in use throw an error and do not create the window + thrower.ThrowError(error_message); + return nullptr; + } + return new BrowserWindow(args, options); } diff --git a/shell/browser/browser_process_impl.cc b/shell/browser/browser_process_impl.cc index cc3b65d308..8660a683a2 100644 --- a/shell/browser/browser_process_impl.cc +++ b/shell/browser/browser_process_impl.cc @@ -43,6 +43,7 @@ #include "services/network/public/cpp/network_switches.h" #include "shell/browser/metrics/electron_metrics_service_client.h" #include "shell/browser/net/resolve_proxy_helper.h" +#include "shell/common/electron_constants.h" #include "shell/common/electron_paths.h" #include "shell/common/thread_restrictions.h" @@ -154,6 +155,8 @@ void BrowserProcessImpl::PostEarlyInitialization() { pref_registry.get()); #endif + pref_registry->RegisterDictionaryPref(electron::kWindowStates); + in_memory_pref_store_ = base::MakeRefCounted<ValueMapPrefStore>(); ApplyProxyModeFromCommandLine(in_memory_pref_store()); prefs_factory.set_command_line_prefs(in_memory_pref_store()); diff --git a/shell/browser/native_window.cc b/shell/browser/native_window.cc index 7aab7963b3..5b4beb9305 100644 --- a/shell/browser/native_window.cc +++ b/shell/browser/native_window.cc @@ -9,21 +9,30 @@ #include <vector> #include "base/memory/ptr_util.h" +#include "base/memory/raw_ptr.h" #include "base/strings/utf_string_conversions.h" #include "base/values.h" +#include "components/prefs/pref_service.h" +#include "components/prefs/scoped_user_pref_update.h" #include "content/public/browser/web_contents_user_data.h" #include "include/core/SkColor.h" #include "shell/browser/background_throttling_source.h" #include "shell/browser/browser.h" +#include "shell/browser/browser_process_impl.h" #include "shell/browser/draggable_region_provider.h" +#include "shell/browser/electron_browser_main_parts.h" #include "shell/browser/ui/drag_util.h" #include "shell/browser/window_list.h" #include "shell/common/color_util.h" +#include "shell/common/electron_constants.h" #include "shell/common/gin_helper/dictionary.h" #include "shell/common/gin_helper/persistent_dictionary.h" #include "shell/common/options_switches.h" #include "ui/base/hit_test.h" #include "ui/compositor/compositor.h" +#include "ui/display/display.h" +#include "ui/display/screen.h" +#include "ui/display/types/display_constants.h" #include "ui/views/widget/widget.h" #if !BUILDFLAG(IS_MAC) @@ -66,6 +75,15 @@ struct Converter<electron::NativeWindow::TitleBarStyle> { namespace electron { +namespace { +// Check if display is fake (default display ID) or has invalid dimensions +bool hasInvalidDisplay(const display::Display& display) { + return display.id() == display::kDefaultDisplayId || + display.size().width() == 0 || display.size().height() == 0; +} + +} // namespace + NativeWindow::NativeWindow(const int32_t base_window_id, const gin_helper::Dictionary& options, NativeWindow* parent) @@ -88,6 +106,38 @@ NativeWindow::NativeWindow(const int32_t base_window_id, options.Get(options::kVibrancyType, &vibrancy_); #endif + options.Get(options::kName, &window_name_); + + if (gin_helper::Dictionary persistence_options; + options.Get(options::kWindowStatePersistence, &persistence_options)) { + // Restore bounds by default + restore_bounds_ = true; + persistence_options.Get(options::kBounds, &restore_bounds_); + // Restore display mode by default + restore_display_mode_ = true; + persistence_options.Get(options::kDisplayMode, &restore_display_mode_); + window_state_persistence_enabled_ = true; + } else if (bool flag; options.Get(options::kWindowStatePersistence, &flag)) { + restore_bounds_ = flag; + restore_display_mode_ = flag; + window_state_persistence_enabled_ = flag; + } + + // Initialize prefs_ to save/restore window bounds if we have a valid window + // name and window state persistence is enabled. + if (window_state_persistence_enabled_ && !window_name_.empty()) { + // Move this out if there's a need to initialize prefs_ for other features + if (auto* browser_process = + electron::ElectronBrowserMainParts::Get()->browser_process()) { + DCHECK(browser_process); + prefs_ = browser_process->local_state(); + } + } else if (window_state_persistence_enabled_ && window_name_.empty()) { + window_state_persistence_enabled_ = false; + LOG(WARNING) << "Window state persistence enabled but no window name " + "provided. Window state will not be persisted."; + } + if (gin_helper::Dictionary dict; options.Get(options::ktitleBarOverlay, &dict)) { titlebar_overlay_ = true; @@ -194,7 +244,14 @@ void NativeWindow::InitFromOptions(const gin_helper::Dictionary& options) { options.Get(options::kFullScreenable, &fullscreenable); SetFullScreenable(fullscreenable); - if (fullscreen) + // Restore window state (bounds and display mode) at this point in + // initialization. We deliberately restore bounds before display modes + // (fullscreen/kiosk) since the target display for these states depends on the + // window's initial bounds. Also, restoring here ensures we respect min/max + // width/height and fullscreenable constraints. + RestoreWindowState(options); + + if (fullscreen && !restore_display_mode_) SetFullScreen(true); if (bool val; options.Get(options::kResizable, &val)) @@ -203,7 +260,8 @@ void NativeWindow::InitFromOptions(const gin_helper::Dictionary& options) { if (bool val; options.Get(options::kSkipTaskbar, &val)) SetSkipTaskbar(val); - if (bool val; options.Get(options::kKiosk, &val) && val) + if (bool val; + options.Get(options::kKiosk, &val) && val && !restore_display_mode_) SetKiosk(val); #if BUILDFLAG(IS_MAC) @@ -223,7 +281,9 @@ void NativeWindow::InitFromOptions(const gin_helper::Dictionary& options) { SetBackgroundColor(background_color); SetTitle(options.ValueOrDefault(options::kTitle, Browser::Get()->GetName())); - + // Save updated window state after restoration adjustments are complete if + // any. + SaveWindowState(); // Then show it. if (options.ValueOrDefault(options::kShow, true)) Show(); @@ -524,6 +584,12 @@ void NativeWindow::NotifyWindowHide() { } void NativeWindow::NotifyWindowMaximize() { + // A restoration-initiated maximize transition has completed; lift the guard + // so subsequent state changes are persisted again. + if (awaiting_restore_display_mode_transition_) { + awaiting_restore_display_mode_transition_ = false; + is_being_restored_ = false; + } observers_.Notify(&NativeWindowObserver::OnWindowMaximize); } @@ -570,6 +636,13 @@ void NativeWindow::NotifyWindowMoved() { } void NativeWindow::NotifyWindowEnterFullScreen() { + // A restoration-initiated fullscreen (or kiosk, which routes through + // fullscreen) transition has completed; lift the guard so subsequent state + // changes are persisted again. + if (awaiting_restore_display_mode_transition_) { + awaiting_restore_display_mode_transition_ = false; + is_being_restored_ = false; + } NotifyLayoutWindowControlsOverlay(); observers_.Notify(&NativeWindowObserver::OnWindowEnterFullScreen); } @@ -636,6 +709,10 @@ void NativeWindow::NotifyLayoutWindowControlsOverlay() { *bounds); } +void NativeWindow::NotifyWindowStateRestored() { + observers_.Notify(&NativeWindowObserver::OnWindowStateRestored); +} + #if BUILDFLAG(IS_WIN) void NativeWindow::NotifyWindowMessage(UINT message, WPARAM w_param, @@ -740,10 +817,14 @@ void NativeWindow::SetAccessibleTitle(const std::string& title) { WidgetDelegate::SetAccessibleTitle(base::UTF8ToUTF16(title)); } -std::string NativeWindow::GetAccessibleTitle() { +std::string NativeWindow::GetAccessibleTitle() const { return base::UTF16ToUTF8(GetAccessibleWindowTitle()); } +std::string NativeWindow::GetName() const { + return window_name_; +} + void NativeWindow::HandlePendingFullscreenTransitions() { if (pending_transitions_.empty()) { set_fullscreen_transition_type(FullScreenTransitionType::kNone); @@ -776,6 +857,282 @@ bool NativeWindow::IsTranslucent() const { return false; } +void NativeWindow::DebouncedSaveWindowState() { + save_window_state_timer_.Start( + FROM_HERE, base::Milliseconds(200), + base::BindOnce(&NativeWindow::SaveWindowState, base::Unretained(this))); +} + +void NativeWindow::SaveWindowState() { + if (!window_state_persistence_enabled_ || is_being_restored_ || + is_transitioning_fullscreen()) + return; + + gfx::Rect bounds = GetBounds(); + + if (bounds.width() == 0 || bounds.height() == 0) { + LOG(WARNING) << "Window state not saved - window bounds are invalid"; + return; + } + + const display::Screen* screen = display::Screen::Get(); + DCHECK(screen); + // GetDisplayMatching returns a fake display with 1920x1080 resolution at + // (0,0) when no physical displays are attached. + // https://source.chromium.org/chromium/chromium/src/+/main:ui/display/display.cc;l=184;drc=e4f1aef5f3ec30a28950d766612cc2c04c822c71 + const display::Display display = screen->GetDisplayMatching(bounds); + + // Skip window state persistence when display has invalid dimensions (0x0) or + // is fake (ID 0xFF). Invalid displays could cause incorrect window bounds to + // be saved, leading to positioning issues during restoration. + // https://source.chromium.org/chromium/chromium/src/+/main:ui/display/types/display_constants.h;l=28;drc=e4f1aef5f3ec30a28950d766612cc2c04c822c71 + if (hasInvalidDisplay(display)) { + LOG(WARNING) + << "Window state not saved - no physical display attached or current " + "display has invalid bounds"; + return; + } + + ScopedDictPrefUpdate update(prefs_, electron::kWindowStates); + const base::DictValue* existing_prefs = update->FindDict(window_name_); + + // When the window is in a special display mode (fullscreen, kiosk, or + // maximized), save the previously stored window bounds instead of + // the current bounds. This ensures that when the window is restored, it can + // be restored to its original position and size if display mode is not + // preserved via windowStatePersistence. + if (!IsNormal() && existing_prefs) { + std::optional<int> left = existing_prefs->FindInt(electron::kLeft); + std::optional<int> top = existing_prefs->FindInt(electron::kTop); + std::optional<int> right = existing_prefs->FindInt(electron::kRight); + std::optional<int> bottom = existing_prefs->FindInt(electron::kBottom); + + if (left && top && right && bottom) { + bounds = gfx::Rect(*left, *top, *right - *left, *bottom - *top); + } + } + + base::DictValue window_preferences; + window_preferences.Set(electron::kLeft, bounds.x()); + window_preferences.Set(electron::kTop, bounds.y()); + window_preferences.Set(electron::kRight, bounds.right()); + window_preferences.Set(electron::kBottom, bounds.bottom()); + + window_preferences.Set(electron::kMaximized, IsMaximized()); + window_preferences.Set(electron::kFullscreen, IsFullscreen()); + window_preferences.Set(electron::kKiosk, IsKiosk()); + + gfx::Rect work_area = display.work_area(); + + window_preferences.Set(electron::kWorkAreaLeft, work_area.x()); + window_preferences.Set(electron::kWorkAreaTop, work_area.y()); + window_preferences.Set(electron::kWorkAreaRight, work_area.right()); + window_preferences.Set(electron::kWorkAreaBottom, work_area.bottom()); + + update->Set(window_name_, std::move(window_preferences)); +} + +void NativeWindow::FlushWindowState() { + // Window destroyed before Show()/ShowInactive() ever ran the deferred + // restore: drop the pending callback and clear the guard so the final save + // isn't suppressed. During a real restoration transition the callback has + // already been moved out, so this is a no-op and transitional bounds stay + // suppressed. + if (restore_display_mode_callback_) { + restore_display_mode_callback_.Reset(); + is_being_restored_ = false; + awaiting_restore_display_mode_transition_ = false; + } + + if (save_window_state_timer_.IsRunning()) { + save_window_state_timer_.FireNow(); + } else { + SaveWindowState(); + } +} + +void NativeWindow::RestoreWindowState(const gin_helper::Dictionary& options) { + if (!window_state_persistence_enabled_) + return; + + const base::Value& value = prefs_->GetValue(electron::kWindowStates); + const base::DictValue* window_preferences = + value.is_dict() ? value.GetDict().FindDict(window_name_) : nullptr; + + if (!window_preferences) + return; + + std::optional<int> saved_left = window_preferences->FindInt(electron::kLeft); + std::optional<int> saved_top = window_preferences->FindInt(electron::kTop); + std::optional<int> saved_right = + window_preferences->FindInt(electron::kRight); + std::optional<int> saved_bottom = + window_preferences->FindInt(electron::kBottom); + + std::optional<int> work_area_left = + window_preferences->FindInt(electron::kWorkAreaLeft); + std::optional<int> work_area_top = + window_preferences->FindInt(electron::kWorkAreaTop); + std::optional<int> work_area_right = + window_preferences->FindInt(electron::kWorkAreaRight); + std::optional<int> work_area_bottom = + window_preferences->FindInt(electron::kWorkAreaBottom); + + if (!saved_left || !saved_top || !saved_right || !saved_bottom || + !work_area_left || !work_area_top || !work_area_right || + !work_area_bottom) { + LOG(WARNING) << "Window state not restored - corrupted values found"; + return; + } + + gfx::Rect saved_bounds = + gfx::Rect(*saved_left, *saved_top, *saved_right - *saved_left, + *saved_bottom - *saved_top); + + display::Screen* screen = display::Screen::Get(); + DCHECK(screen); + + // Set the primary display as the target display for restoration. + display::Display display = screen->GetPrimaryDisplay(); + + // We identify the display with the minimal Manhattan distance to the saved + // bounds and set it as the target display for restoration. + int min_displacement = std::numeric_limits<int>::max(); + + for (const auto& candidate : screen->GetAllDisplays()) { + gfx::Rect test_bounds = saved_bounds; + test_bounds.AdjustToFit(candidate.work_area()); + int displacement = std::abs(test_bounds.x() - saved_bounds.x()) + + std::abs(test_bounds.y() - saved_bounds.y()); + + if (displacement < min_displacement) { + min_displacement = displacement; + display = candidate; + } + } + + // Skip window state restoration if current display has invalid dimensions or + // is fake. Restoring from invalid displays (0x0) or fake displays (ID 0xFF) + // could cause incorrect window positioning when later moved to real displays. + // https://source.chromium.org/chromium/chromium/src/+/main:ui/display/types/display_constants.h;l=28;drc=e4f1aef5f3ec30a28950d766612cc2c04c822c71 + if (hasInvalidDisplay(display)) { + LOG(WARNING) << "Window state not restored - no physical display attached " + "or current display has invalid bounds"; + return; + } + + gfx::Rect saved_work_area = gfx::Rect(*work_area_left, *work_area_top, + *work_area_right - *work_area_left, + *work_area_bottom - *work_area_top); + + // Set this to true before RestoreBounds to prevent SaveWindowState from being + // inadvertently triggered during the restoration process. + is_being_restored_ = true; + + if (restore_bounds_) { + RestoreBounds(display, saved_work_area, saved_bounds); + } + + if (restore_display_mode_) { + // The display-mode change (kiosk/fullscreen/maximize) runs from Show()/ + // ShowInactive() via FlushPendingDisplayMode(). It triggers resize/move + // events that schedule DebouncedSaveWindowState. Keep is_being_restored_ + // true until the transition completes so transitional bounds aren't + // persisted over the saved ones. Because these transitions are async on + // macOS, the reset is deferred to the matching Notify* observer + // (NotifyWindowEnterFullScreen/NotifyWindowMaximize) rather than reset + // synchronously here. + restore_display_mode_callback_ = base::BindOnce( + [](NativeWindow* window, base::DictValue prefs) { + bool transition_initiated = true; + if (auto kiosk = prefs.FindBool(electron::kKiosk); kiosk && *kiosk) { + window->awaiting_restore_display_mode_transition_ = true; + window->SetKiosk(true); + } else if (auto fs = prefs.FindBool(electron::kFullscreen); + fs && *fs) { + window->awaiting_restore_display_mode_transition_ = true; + window->SetFullScreen(true); + } else if (auto max = prefs.FindBool(electron::kMaximized); + max && *max) { + window->awaiting_restore_display_mode_transition_ = true; + window->Maximize(); + } else { + transition_initiated = false; + } + // No display-mode transition to wait on (saved state was normal): + // lift the guard immediately. + if (!transition_initiated) + window->is_being_restored_ = false; + }, + base::Unretained(this), window_preferences->Clone()); + } else { + is_being_restored_ = false; + } + + NotifyWindowStateRestored(); +} + +void NativeWindow::FlushPendingDisplayMode() { + if (restore_display_mode_callback_) { + std::move(restore_display_mode_callback_).Run(); + } +} + +// This function is similar to Chromium's window bounds adjustment logic +// https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/window_sizer/window_sizer.cc;l=350;drc=0ec56065ba588552f21633aa47280ba02c3cd160 +void NativeWindow::RestoreBounds(const display::Display& display, + const gfx::Rect& saved_work_area, + gfx::Rect& saved_bounds) { + if (saved_bounds.width() == 0 || saved_bounds.height() == 0) { + LOG(WARNING) << "Window bounds not restored - values are invalid"; + return; + } + + // Ensure that the window is at least kMinVisibleHeight * kMinVisibleWidth. + saved_bounds.set_height(std::max(kMinVisibleHeight, saved_bounds.height())); + saved_bounds.set_width(std::max(kMinVisibleWidth, saved_bounds.width())); + + const gfx::Rect work_area = display.work_area(); + // Ensure that the title bar is not above the work area. + if (saved_bounds.y() < work_area.y()) { + saved_bounds.set_y(work_area.y()); + } + + // Reposition and resize the bounds if the saved_work_area is different from + // the current work area and the current work area doesn't completely contain + // the bounds. + if (!saved_work_area.IsEmpty() && saved_work_area != work_area && + !work_area.Contains(saved_bounds)) { + saved_bounds.AdjustToFit(work_area); + } + +#if BUILDFLAG(IS_MAC) + // On mac, we want to be aggressive about repositioning windows that are + // partially offscreen. If the window is partially offscreen horizontally, + // snap to the nearest edge of the work area. This call also adjusts the + // height, width if needed to make the window fully visible. + saved_bounds.AdjustToFit(work_area); +#else + // On non-Mac platforms, we are less aggressive about repositioning. Simply + // ensure that at least kMinVisibleWidth * kMinVisibleHeight is visible + + const int min_y = work_area.y() + kMinVisibleHeight - saved_bounds.height(); + const int min_x = work_area.x() + kMinVisibleWidth - saved_bounds.width(); + const int max_y = work_area.bottom() - kMinVisibleHeight; + const int max_x = work_area.right() - kMinVisibleWidth; + // Reposition and resize the bounds to make it fully visible inside the work + // area. `min_x >= max_x` happens when work area and bounds are both small. + if (min_x >= max_x || min_y >= max_y) { + saved_bounds.AdjustToFit(work_area); + } else { + saved_bounds.set_y(std::clamp(saved_bounds.y(), min_y, max_y)); + saved_bounds.set_x(std::clamp(saved_bounds.x(), min_x, max_x)); + } +#endif // BUILDFLAG(IS_MAC) + + SetBounds(saved_bounds, false); +} + // static bool NativeWindow::PlatformHasClientFrame() { #if defined(USE_OZONE) diff --git a/shell/browser/native_window.h b/shell/browser/native_window.h index ae7fe3d3f2..7b60bb3b1f 100644 --- a/shell/browser/native_window.h +++ b/shell/browser/native_window.h @@ -17,6 +17,8 @@ #include "base/memory/weak_ptr.h" #include "base/observer_list.h" #include "base/strings/cstring_view.h" +#include "base/supports_user_data.h" +#include "base/timer/timer.h" #include "content/public/browser/desktop_media_id.h" #include "content/public/browser/web_contents_user_data.h" #include "extensions/browser/app_window/size_constraints.h" @@ -26,6 +28,7 @@ class SkRegion; class DraggableRegionProvider; +class PrefService; namespace input { struct NativeWebKeyboardEvent; @@ -165,9 +168,11 @@ class NativeWindow : public views::WidgetDelegate { void SetTitle(std::string_view title); [[nodiscard]] std::string GetTitle() const; + [[nodiscard]] std::string GetName() const; + // Ability to augment the window title for the screen readers. void SetAccessibleTitle(const std::string& title); - std::string GetAccessibleTitle(); + [[nodiscard]] std::string GetAccessibleTitle() const; virtual void FlashFrame(bool flash) = 0; virtual void SetSkipTaskbar(bool skip) = 0; @@ -340,6 +345,7 @@ class NativeWindow : public views::WidgetDelegate { void NotifyNewWindowForTab(); void NotifyWindowSystemContextMenu(int x, int y, bool* prevent_default); void NotifyLayoutWindowControlsOverlay(); + void NotifyWindowStateRestored(); #if BUILDFLAG(IS_WIN) void NotifyWindowMessage(UINT message, WPARAM w_param, LPARAM l_param); @@ -438,6 +444,28 @@ class NativeWindow : public views::WidgetDelegate { [[nodiscard]] auto base_window_id() const { return base_window_id_; } + // Saves current window state to the Local State JSON file in + // app.getPath('userData') via PrefService. + // This does NOT immediately write to disk - it updates the in-memory + // preference store and queues an asynchronous write operation. The actual + // disk write is batched and flushed later. + void SaveWindowState(); + void DebouncedSaveWindowState(); + // Flushes save_window_state_timer_ that was queued by + // DebouncedSaveWindowState. This does NOT flush the actual disk write. + void FlushWindowState(); + + // Restores window state - bounds first and then display mode. + void RestoreWindowState(const gin_helper::Dictionary& options); + // Applies saved bounds to the window. + void RestoreBounds(const display::Display& display, + const gfx::Rect& saved_work_area, + gfx::Rect& saved_bounds); + // Flushes pending display mode restoration (fullscreen, maximized, kiosk) + // that was deferred during initialization to respect show=false. This + // consumes and clears the restore_display_mode_callback_. + void FlushPendingDisplayMode(); + protected: NativeWindow(int32_t base_window_id, const gin_helper::Dictionary& options, @@ -503,6 +531,10 @@ class NativeWindow : public views::WidgetDelegate { // ID of the api::BaseWindow that owns this NativeWindow. const int32_t base_window_id_; + // Identifier for the window provided by the application. + // Used by Electron internally for features such as state persistence. + std::string window_name_; + // The "titleBarStyle" option. const TitleBarStyle title_bar_style_; @@ -564,6 +596,37 @@ class NativeWindow : public views::WidgetDelegate { gfx::Rect overlay_rect_; + // Flag to prevent SaveWindowState calls during window restoration. + bool is_being_restored_ = false; + + // True while a restoration-initiated display-mode transition + // (fullscreen/maximize/kiosk) is in flight. Cleared from the matching + // Notify* observer once the (possibly async) transition completes. + bool awaiting_restore_display_mode_transition_ = false; + + // The boolean parsing of the "windowStatePersistence" option + bool window_state_persistence_enabled_ = false; + + // PrefService is used to persist window bounds and state. + // Only populated when windowStatePersistence is enabled and window has a + // valid name. + raw_ptr<PrefService> prefs_ = nullptr; + + // Whether to restore bounds. + bool restore_bounds_ = false; + // Whether to restore display mode. + bool restore_display_mode_ = false; + // Callback to restore display mode. + base::OnceCallback<void()> restore_display_mode_callback_; + + // Timer to debounce window state saving operations. + base::OneShotTimer save_window_state_timer_; + + // Minimum height of the visible part of a window. + const int kMinVisibleHeight = 100; + // Minimum width of the visible part of a window. + const int kMinVisibleWidth = 100; + raw_ptr<InspectableWebContentsView> primary_web_contents_view_ = nullptr; base::WeakPtrFactory<NativeWindow> weak_factory_{this}; diff --git a/shell/browser/native_window_mac.mm b/shell/browser/native_window_mac.mm index 6c18cd6aa5..b54f26d254 100644 --- a/shell/browser/native_window_mac.mm +++ b/shell/browser/native_window_mac.mm @@ -470,6 +470,8 @@ void NativeWindowMac::Show() { return; } + FlushPendingDisplayMode(); + set_wants_to_be_visible(true); // Reattach the window to the parent to actually show it. @@ -484,6 +486,8 @@ void NativeWindowMac::Show() { } void NativeWindowMac::ShowInactive() { + FlushPendingDisplayMode(); + set_wants_to_be_visible(true); // Reattach the window to the parent to actually show it. diff --git a/shell/browser/native_window_observer.h b/shell/browser/native_window_observer.h index 82bb2aed9c..5267746fcd 100644 --- a/shell/browser/native_window_observer.h +++ b/shell/browser/native_window_observer.h @@ -110,6 +110,8 @@ class NativeWindowObserver : public base::CheckedObserver { virtual void OnExecuteAppCommand(std::string_view command_name) {} virtual void UpdateWindowControlsOverlay(const gfx::Rect& bounding_rect) {} + + virtual void OnWindowStateRestored() {} }; } // namespace electron diff --git a/shell/browser/native_window_views.cc b/shell/browser/native_window_views.cc index 4c70fe9964..2b3cb1f449 100644 --- a/shell/browser/native_window_views.cc +++ b/shell/browser/native_window_views.cc @@ -592,6 +592,8 @@ void NativeWindowViews::Show() { if (is_modal() && NativeWindow::parent() && !widget()->IsVisible()) static_cast<NativeWindowViews*>(parent())->IncrementChildModals(); + FlushPendingDisplayMode(); + widget()->native_widget_private()->Show(GetRestoredState(), gfx::Rect()); // explicitly focus the window @@ -611,6 +613,8 @@ void NativeWindowViews::Show() { } void NativeWindowViews::ShowInactive() { + FlushPendingDisplayMode(); + widget()->ShowInactive(); NotifyWindowShow(); diff --git a/shell/common/electron_constants.h b/shell/common/electron_constants.h index b29351ba6d..79ab830c8c 100644 --- a/shell/common/electron_constants.h +++ b/shell/common/electron_constants.h @@ -21,6 +21,23 @@ inline constexpr std::string_view kDeviceVendorIdKey = "vendorId"; inline constexpr std::string_view kDeviceProductIdKey = "productId"; inline constexpr std::string_view kDeviceSerialNumberKey = "serialNumber"; +// Window state preference keys +inline constexpr std::string_view kLeft = "left"; +inline constexpr std::string_view kTop = "top"; +inline constexpr std::string_view kRight = "right"; +inline constexpr std::string_view kBottom = "bottom"; + +inline constexpr std::string_view kMaximized = "maximized"; +inline constexpr std::string_view kFullscreen = "fullscreen"; +inline constexpr std::string_view kKiosk = "kiosk"; + +inline constexpr std::string_view kWorkAreaLeft = "workAreaLeft"; +inline constexpr std::string_view kWorkAreaTop = "workAreaTop"; +inline constexpr std::string_view kWorkAreaRight = "workAreaRight"; +inline constexpr std::string_view kWorkAreaBottom = "workAreaBottom"; + +inline constexpr std::string_view kWindowStates = "windowStates"; + inline constexpr base::cstring_view kRunAsNode = "ELECTRON_RUN_AS_NODE"; // Per-profile UUID to distinguish global shortcut sessions for diff --git a/shell/common/options_switches.h b/shell/common/options_switches.h index 1d814fef37..ff2bb847a3 100644 --- a/shell/common/options_switches.h +++ b/shell/common/options_switches.h @@ -107,6 +107,19 @@ inline constexpr std::string_view kFocusable = "focusable"; // The WebPreferences. inline constexpr std::string_view kWebPreferences = "webPreferences"; +// Window state persistence for BaseWindow +inline constexpr std::string_view kWindowStatePersistence = + "windowStatePersistence"; + +// Identifier for the window provided by the application +inline constexpr std::string_view kName = "name"; + +// Whether to save the window bounds +inline constexpr std::string_view kBounds = "bounds"; + +// Whether to save the window display mode +inline constexpr std::string_view kDisplayMode = "displayMode"; + // Add a vibrancy effect to the browser window inline constexpr std::string_view kVibrancyType = "vibrancy"; diff --git a/spec/api-browser-window-spec.ts b/spec/api-browser-window-spec.ts index 932f94f01d..2f572a7dc8 100755 --- a/spec/api-browser-window-spec.ts +++ b/spec/api-browser-window-spec.ts @@ -1,6 +1,8 @@ import { app, BrowserWindow, + BaseWindow, + BrowserWindowConstructorOptions, BrowserView, dialog, ipcMain, @@ -88,6 +90,8 @@ describe('BrowserWindow module', () => { }); describe('BrowserWindow constructor', () => { + afterEach(closeAllWindows); + it('allows passing void 0 as the webContents', async () => { expect(() => { const w = new BrowserWindow({ @@ -115,6 +119,38 @@ describe('BrowserWindow module', () => { }).not.to.throw(); }); + it('throws error when creating windows with duplicate names', () => { + const w1 = new BrowserWindow({ show: false, name: 'duplicate-name' }); + + expect(() => { + // eslint-disable-next-line no-new + new BrowserWindow({ show: false, name: 'duplicate-name' }); + }).to.throw("Window name 'duplicate-name' is already in use. Window names must be unique."); + + w1.destroy(); + }); + + it('prevents BaseWindow and BrowserWindow from using same name', () => { + const base = new BaseWindow({ show: false, name: 'shared-name' }); + + expect(() => { + // eslint-disable-next-line no-new + new BrowserWindow({ show: false, name: 'shared-name' }); + }).to.throw("Window name 'shared-name' is already in use. Window names must be unique."); + + base.destroy(); + }); + + it('allows reusing name after window is destroyed', () => { + const w1 = new BrowserWindow({ show: false, name: 'reusable-name' }); + w1.destroy(); + + expect(() => { + const w2 = new BrowserWindow({ show: false, name: 'reusable-name' }); + w2.destroy(); + }).not.to.throw(); + }); + for (const frame of [true, false]) { ifit(process.platform !== 'darwin')(`creates a window centered at the requested size (frame: ${frame})`, () => { const w = new BrowserWindow({ show: false, frame, width: 600, height: 400 }); @@ -7735,4 +7771,1615 @@ describe('BrowserWindow module', () => { await screenCapture.expectColorAtCenterMatches(HexColors.BLUE); }); }); + + ifdescribe(hasCapturableScreen())('windowStatePersistence', () => { + const getWindowStateFromDisk = (windowName: string, preferencesPath: string) => { + if (!fs.existsSync(preferencesPath)) { + throw new Error( + `Preferences file does not exist at path: ${preferencesPath}. Window state was not saved to disk.` + ); + } + const prefsContent = fs.readFileSync(preferencesPath, 'utf8'); + const prefs = JSON.parse(prefsContent); + return prefs?.windowStates?.[windowName] || null; + }; + + // Helper to get preferences file modification time + const getPrefsModTime = (preferencesPath: string): Date => { + try { + return fs.statSync(preferencesPath).mtime; + } catch { + throw new Error(`Test requires preferences file to exist at path: ${preferencesPath}.`); + } + }; + + const waitForPrefsUpdate = async (initialModTime: Date, preferencesPath: string): Promise<void> => { + const startTime = Date.now(); + const timeoutMs = 20000; + while (true) { + const currentModTime = getPrefsModTime(preferencesPath); + + if (currentModTime > initialModTime) { + return; + } + + if (Date.now() - startTime > timeoutMs) { + throw new Error(`Window state was not flushed to disk within ${timeoutMs}ms`); + } + // Wait for 1 second before checking again + await setTimeout(1000); + } + }; + + const waitForPrefsFileCreation = async (preferencesPath: string) => { + while (!fs.existsSync(preferencesPath)) { + await setTimeout(1000); + } + }; + + const createAndSaveWindowState = async ( + preferencesPath: string, + windowName: string, + options?: BrowserWindowConstructorOptions + ) => { + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: { + displayMode: false + }, + show: false, + ...options + }); + if (!fs.existsSync(preferencesPath)) { + // File doesn't exist, wait for creation + await waitForPrefsFileCreation(preferencesPath); + } else { + // File exists, wait for update + const initialModTime = getPrefsModTime(preferencesPath); + await waitForPrefsUpdate(initialModTime, preferencesPath); + } + // Ensure window is destroyed because we can't create another window with the same name otherwise + w.destroy(); + }; + + describe('save window state', () => { + const fixturesPath = path.resolve(__dirname, 'fixtures', 'api', 'window-state-save'); + const sharedUserDataPath = path.join(os.tmpdir(), 'electron-window-state-test'); + const sharedPreferencesPath = path.join(sharedUserDataPath, 'Local State'); + + // Clean up before each test + beforeEach(() => { + if (fs.existsSync(sharedUserDataPath)) { + fs.rmSync(sharedUserDataPath, { recursive: true, force: true }); + } + }); + + describe('state saving after window operations', () => { + it('should save window state with required properties', async () => { + const appPath = path.join(fixturesPath, 'schema-check'); + const appProcess = childProcess.spawn(process.execPath, [appPath]); + const [code] = await once(appProcess, 'exit'); + expect(code).to.equal(0); + + const savedState = getWindowStateFromDisk('test-window-state-schema', sharedPreferencesPath); + expect(savedState).to.not.be.null('window state with window name "test-window-state-schema" does not exist'); + expect(savedState).to.have.property('left'); + expect(savedState).to.have.property('top'); + expect(savedState).to.have.property('right'); + expect(savedState).to.have.property('bottom'); + expect(savedState).to.have.property('maximized'); + expect(savedState).to.have.property('fullscreen'); + expect(savedState).to.have.property('kiosk'); + expect(savedState).to.have.property('workAreaLeft'); + expect(savedState).to.have.property('workAreaTop'); + expect(savedState).to.have.property('workAreaRight'); + expect(savedState).to.have.property('workAreaBottom'); + }); + + it('should save window state after window is closed and app exit', async () => { + const appPath = path.join(fixturesPath, 'close-save'); + const appProcess = childProcess.spawn(process.execPath, [appPath]); + const [code] = await once(appProcess, 'exit'); + expect(code).to.equal(0); + + const savedState = getWindowStateFromDisk('test-close-save', sharedPreferencesPath); + expect(savedState).to.not.be.null('window state with window name "test-close-save" does not exist'); + expect(savedState.right - savedState.left).to.equal(400); + expect(savedState.bottom - savedState.top).to.equal(300); + expect(savedState.maximized).to.equal(false); + expect(savedState.fullscreen).to.equal(false); + expect(savedState.kiosk).to.equal(false); + }); + + it('should save window state after window is resized and app exit', async () => { + const appPath = path.join(fixturesPath, 'resize-save'); + const appProcess = childProcess.spawn(process.execPath, [appPath]); + const [code] = await once(appProcess, 'exit'); + expect(code).to.equal(0); + + const savedState = getWindowStateFromDisk('test-resize-save', sharedPreferencesPath); + expect(savedState).to.not.be.null('window state with window name "test-resize-save" does not exist'); + expect(savedState.right - savedState.left).to.equal(500); + expect(savedState.bottom - savedState.top).to.equal(400); + expect(savedState.maximized).to.equal(false); + expect(savedState.fullscreen).to.equal(false); + expect(savedState.kiosk).to.equal(false); + }); + + it('should save window state after window is moved and app exit', async () => { + const appPath = path.join(fixturesPath, 'move-save'); + const appProcess = childProcess.spawn(process.execPath, [appPath]); + const [code] = await once(appProcess, 'exit'); + expect(code).to.equal(0); + + const savedState = getWindowStateFromDisk('test-move-save', sharedPreferencesPath); + expect(savedState).to.not.be.null('window state with window name "test-move-save" does not exist'); + expect(savedState.left).to.equal(100); + expect(savedState.top).to.equal(150); + expect(savedState.maximized).to.equal(false); + expect(savedState.fullscreen).to.equal(false); + expect(savedState.kiosk).to.equal(false); + }); + + it('should save window state after window is fullscreened and app exit', async () => { + const appPath = path.join(fixturesPath, 'fullscreen-save'); + const appProcess = childProcess.spawn(process.execPath, [appPath]); + const [code] = await once(appProcess, 'exit'); + expect(code).to.equal(0); + + const savedState = getWindowStateFromDisk('test-fullscreen-save', sharedPreferencesPath); + expect(savedState).to.not.be.null('window state with window name "test-fullscreen-save" does not exist'); + expect(savedState.fullscreen).to.equal(true); + expect(savedState.maximized).to.equal(false); + expect(savedState.kiosk).to.equal(false); + }); + + it('should save window state after window is maximized and app exit', async () => { + const appPath = path.join(fixturesPath, 'maximize-save'); + const appProcess = childProcess.spawn(process.execPath, [appPath]); + const [code] = await once(appProcess, 'exit'); + expect(code).to.equal(0); + + const savedState = getWindowStateFromDisk('test-maximize-save', sharedPreferencesPath); + expect(savedState).to.not.be.null('window state with window name "test-maximize-save" does not exist'); + expect(savedState.maximized).to.equal(true); + expect(savedState.fullscreen).to.equal(false); + expect(savedState.kiosk).to.equal(false); + }); + + it('should save window state if in a minimized state and app exit', async () => { + const appPath = path.join(fixturesPath, 'minimize-save'); + const appProcess = childProcess.spawn(process.execPath, [appPath]); + const [code] = await once(appProcess, 'exit'); + expect(code).to.equal(0); + + const savedState = getWindowStateFromDisk('test-minimize-save', sharedPreferencesPath); + expect(savedState).to.not.be.null('window state with window name "test-minimize-save" does not exist'); + // Should save the bounds from before minimizing + expect(savedState.right - savedState.left).to.equal(400); + expect(savedState.bottom - savedState.top).to.equal(300); + expect(savedState.maximized).to.equal(false); + expect(savedState.fullscreen).to.equal(false); + expect(savedState.kiosk).to.equal(false); + }); + + it('should save window state after window is kiosked and app exit', async () => { + const appPath = path.join(fixturesPath, 'kiosk-save'); + const appProcess = childProcess.spawn(process.execPath, [appPath]); + const [code] = await once(appProcess, 'exit'); + expect(code).to.equal(0); + + const savedState = getWindowStateFromDisk('test-kiosk-save', sharedPreferencesPath); + expect(savedState).to.not.be.null('window state with window name "test-kiosk-save" does not exist'); + expect(savedState.kiosk).to.equal(true); + expect(savedState.fullscreen).to.equal(true); + expect(savedState.maximized).to.equal(false); + }); + }); + + describe('work area tests', () => { + it('should save valid work area bounds', async () => { + const appPath = path.join(fixturesPath, 'schema-check'); + const appProcess = childProcess.spawn(process.execPath, [appPath]); + const [code] = await once(appProcess, 'exit'); + expect(code).to.equal(0); + + const savedState = getWindowStateFromDisk('test-window-state-schema', sharedPreferencesPath); + + expect(savedState).to.not.be.null('window state with window name "test-window-state-schema" does not exist'); + expect(savedState.workAreaLeft).to.be.a('number'); + expect(savedState.workAreaTop).to.be.a('number'); + expect(savedState.workAreaRight).to.be.a('number'); + expect(savedState.workAreaBottom).to.be.a('number'); + + expect(savedState.workAreaLeft).to.be.lessThan(savedState.workAreaRight); + expect(savedState.workAreaTop).to.be.lessThan(savedState.workAreaBottom); + }); + + it('should save work area bounds that contain the window bounds on primary display', async () => { + // Fixture will center the window on the primary display + const appPath = path.join(fixturesPath, 'work-area-primary'); + const appProcess = childProcess.spawn(process.execPath, [appPath]); + const [code] = await once(appProcess, 'exit'); + expect(code).to.equal(0); + + const savedState = getWindowStateFromDisk('test-work-area-primary', sharedPreferencesPath); + expect(savedState).to.not.be.null('window state with window name "test-work-area-primary" does not exist'); + + expect(savedState.left).to.be.greaterThanOrEqual(savedState.workAreaLeft); + expect(savedState.top).to.be.greaterThanOrEqual(savedState.workAreaTop); + expect(savedState.right).to.be.lessThanOrEqual(savedState.workAreaRight); + expect(savedState.bottom).to.be.lessThanOrEqual(savedState.workAreaBottom); + }); + }); + + describe('asynchronous batching behavior', () => { + let w: BrowserWindow; + const windowName = 'test-batching-behavior'; + const preferencesPath = path.join(app.getPath('userData'), 'Local State'); + + beforeEach(async () => { + await setTimeout(2000); + BrowserWindow.clearPersistedState(windowName); + w = new BrowserWindow({ + show: false, + width: 400, + height: 300, + name: windowName, + windowStatePersistence: true + }); + }); + + afterEach(closeAllWindows); + + it('should not immediately save window state to disk when window is moved/resized', async () => { + // Wait for preferences file to be created if its the first time we're running the test + await waitForPrefsFileCreation(preferencesPath); + + const initialModTime = getPrefsModTime(preferencesPath); + + const moved = once(w, 'move'); + w.setPosition(150, 200); + await moved; + // Wait for any potential save to occur from the move operation + await setTimeout(1000); + + const resized = once(w, 'resize'); + w.setSize(500, 400); + await resized; + // Wait for any potential save to occur from the resize operation + await setTimeout(1000); + + const afterMoveModTime = getPrefsModTime(preferencesPath); + + expect(afterMoveModTime.getTime()).to.equal(initialModTime.getTime()); + }); + + it('should eventually flush window state to disk after batching period', async () => { + // Wait for preferences file to be created if its the first time we're running the test + await waitForPrefsFileCreation(preferencesPath); + + const initialModTime = getPrefsModTime(preferencesPath); + + const resized = once(w, 'resize'); + w.setSize(500, 400); + await resized; + + await waitForPrefsUpdate(initialModTime, preferencesPath); + + const savedState = getWindowStateFromDisk(windowName, preferencesPath); + expect(savedState).to.not.be.null('window state with window name "test-batching-behavior" does not exist'); + expect(savedState.right - savedState.left).to.equal(500); + expect(savedState.bottom - savedState.top).to.equal(400); + }); + + it('should batch multiple window operations and save final state', async () => { + // Wait for preferences file to be created if its the first time we're running the test + await waitForPrefsFileCreation(preferencesPath); + + const initialModTime = getPrefsModTime(preferencesPath); + + const resize1 = once(w, 'resize'); + w.setSize(500, 400); + await resize1; + // Wait for any potential save to occur + await setTimeout(1000); + + const afterFirstResize = getPrefsModTime(preferencesPath); + + const resize2 = once(w, 'resize'); + w.setSize(600, 500); + await resize2; + // Wait for any potential save to occur + await setTimeout(1000); + + const afterSecondResize = getPrefsModTime(preferencesPath); + + const resize3 = once(w, 'resize'); + w.setSize(700, 600); + await resize3; + // Wait for any potential save to occur + await setTimeout(1000); + + const afterThirdResize = getPrefsModTime(preferencesPath); + + await waitForPrefsUpdate(initialModTime, preferencesPath); + + const savedState = getWindowStateFromDisk(windowName, preferencesPath); + expect(savedState).to.not.be.null('window state with window name "test-batching-behavior" does not exist'); + + [afterFirstResize, afterSecondResize, afterThirdResize].forEach((time) => { + expect(time.getTime()).to.equal(initialModTime.getTime()); + }); + + expect(savedState.right - savedState.left).to.equal(700); + expect(savedState.bottom - savedState.top).to.equal(600); + }); + }); + + // The main-process busy-loop variant runs in a spawned fixture so the + // spec runner main thread isn't blocked for 25 seconds. + it('should not save window bounds when main thread is busy', async function () { + // Fixture sleeps for 25s plus Electron startup overhead, so allow + // headroom past mocha's 30s default. + this.timeout(60000); + const appPath = path.join(fixturesPath, 'main-thread-busy'); + const appProcess = childProcess.spawn(process.execPath, [appPath]); + const [code] = await once(appProcess, 'exit'); + expect(code).to.equal(0); + }); + }); + + describe('clear window state', () => { + const windowName = 'test-window-clear'; + const preferencesPath = path.join(app.getPath('userData'), 'Local State'); + + beforeEach(async () => { + // Timeout here plays nice with CI + await setTimeout(2000); + // Let's start with a clean slate everytime + BrowserWindow.clearPersistedState(windowName); + }); + + afterEach(closeAllWindows); + + it('should clear existing window state', async () => { + const initialModTime = getPrefsModTime(preferencesPath); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + w.destroy(); + + await waitForPrefsUpdate(initialModTime, preferencesPath); + + const stateBefore = getWindowStateFromDisk(windowName, preferencesPath); + expect(stateBefore).to.not.be.null( + 'window state with window name "test-window-clear" should exist but does not' + ); + + BrowserWindow.clearPersistedState(windowName); + + await waitForPrefsUpdate(getPrefsModTime(preferencesPath), preferencesPath); + + const stateAfter = getWindowStateFromDisk(windowName, preferencesPath); + expect(stateAfter).to.be.null('window state with window name "test-window-clear" should be cleared'); + }); + + it('should clear existing window state from memory immediately', async () => { + const w = new BrowserWindow({ + height: 100, + width: 100, + name: windowName, + windowStatePersistence: true, + show: false + }); + + w.destroy(); + + const w1 = new BrowserWindow({ + height: 200, + width: 200, + name: windowName, + windowStatePersistence: true, + show: false + }); + + // This proves that the window state exists in memory + expect(w1.getBounds().width).to.equal(100); + expect(w1.getBounds().height).to.equal(100); + + w1.destroy(); + + BrowserWindow.clearPersistedState(windowName); + + const w2 = new BrowserWindow({ + height: 200, + width: 200, + name: windowName, + windowStatePersistence: true, + show: false + }); + // windowStatePersistence: true should override the constructor bounds if not cleared + // If the window has dimensions 200x200, it indicates that the state was indeed cleared + expect(w2.getBounds().width).to.equal(200); + expect(w2.getBounds().height).to.equal(200); + + w2.destroy(); + }); + + it('should not throw when clearing non-existent window state', () => { + expect(() => { + BrowserWindow.clearPersistedState('non-existent-window'); + }).to.not.throw(); + }); + + it('should not affect other window states when clearing specific window', async () => { + const windowName1 = 'test-window-1'; + const windowName2 = 'test-window-2'; + const initialModTime = getPrefsModTime(preferencesPath); + + const w1 = new BrowserWindow({ + name: windowName1, + windowStatePersistence: true, + show: false + }); + w1.destroy(); + + const w2 = new BrowserWindow({ + name: windowName2, + windowStatePersistence: true, + show: false + }); + w2.destroy(); + + await waitForPrefsUpdate(initialModTime, preferencesPath); + + expect(getWindowStateFromDisk(windowName1, preferencesPath)).to.not.be.null( + 'window state with window name "test-window-1" should exist but does not' + ); + expect(getWindowStateFromDisk(windowName2, preferencesPath)).to.not.be.null( + 'window state with window name "test-window-2" should exist but does not' + ); + + BrowserWindow.clearPersistedState(windowName1); + + await waitForPrefsUpdate(getPrefsModTime(preferencesPath), preferencesPath); + + // Verify if only window1 was cleared + expect(getWindowStateFromDisk(windowName1, preferencesPath)).to.be.null( + 'window state with window name "test-window-1" should be cleared' + ); + expect(getWindowStateFromDisk(windowName2, preferencesPath)).to.not.be.null( + 'window state with window name "test-window-2" should not be cleared' + ); + }); + }); + + describe('restore window state', () => { + const preferencesPath = path.join(app.getPath('userData'), 'Local State'); + const windowName = 'test-restore-window'; + + beforeEach(async () => { + // Timeout here plays nice with CI + await setTimeout(2000); + // Let's start with a clean slate everytime + BrowserWindow.clearPersistedState(windowName); + }); + + afterEach(closeAllWindows); + + describe('single monitor tests', () => { + // Window state restoration takes into account current work area bounds to readjust height/width + // height and width will be readjusted to kMinimumVisibleWidth*kMinimumVisibleHeight (100x100) + // if there is no capturable screen + it('should restore bounds when windowStatePersistence is true', async () => { + const workArea = screen.getPrimaryDisplay().workArea; + const bounds = { width: 100, height: 100, x: workArea.x, y: workArea.y }; + await createAndSaveWindowState(preferencesPath, windowName, bounds); + // Should override default constructor bounds + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + width: 500, + height: 400, + x: 200, + y: 250, + show: false + }); + + expectBoundsEqual(w.getBounds(), bounds); + + w.destroy(); + }); + + it('should use default window options when no saved state exists', async () => { + const defaultBounds = { width: 500, height: 400, x: 200, y: 250 }; + // BrowserWindow.clearPersistedState(windowName) is called in beforeEach + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + ...defaultBounds, + show: false + }); + // Should take the default bounds from the constructor as there is no saved state + expectBoundsEqual(w.getBounds(), defaultBounds); + expect(w.isFullScreen()).to.equal(false); + expect(w.isMaximized()).to.equal(false); + + w.destroy(); + }); + + it('should restore fullscreen state when windowStatePersistence is true', async () => { + await createAndSaveWindowState(preferencesPath, windowName, { fullscreen: true }); + await setTimeout(2000); + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true + }); + + const enterFullScreen = once(w, 'enter-full-screen'); + if (!w.isFullScreen()) await enterFullScreen; + + expect(w.isFullScreen()).to.equal(true); + + w.destroy(); + }); + + it('should restore kiosk state when windowStatePersistence is true', async () => { + await createAndSaveWindowState(preferencesPath, windowName, { kiosk: true }); + await setTimeout(2000); + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true + }); + + const enterFullScreen = once(w, 'enter-full-screen'); + if (!w.isFullScreen()) await enterFullScreen; + + expect(w.isFullScreen()).to.equal(true); + expect(w.isKiosk()).to.equal(true); + + w.destroy(); + }); + + it('should restore maximized state when windowStatePersistence is true', async () => { + const width = screen.getPrimaryDisplay().workArea.width; + const height = screen.getPrimaryDisplay().workArea.height; + await createAndSaveWindowState(preferencesPath, windowName, { width, height }); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + const maximized = once(w, 'maximize'); + if (!w.isMaximized()) await maximized; + + expect(w.isMaximized()).to.equal(true); + + w.destroy(); + }); + + it('should not restore state when windowStatePersistence is false', async () => { + const bounds = { width: 400, height: 300, x: 100, y: 150 }; + await createAndSaveWindowState(preferencesPath, windowName, bounds); + + const defaultBounds = { width: 500, height: 400, x: 200, y: 250 }; + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: false, + ...defaultBounds, + show: false + }); + + expectBoundsEqual(w.getBounds(), defaultBounds); + + w.destroy(); + }); + + it('should restore bounds only when displayMode is disabled', async () => { + // We don't use the utility createAndSaveWindowState here because the default bounds + // passed through the constructor get affected by setting fullscreen: true alongside it. + // It particularly affects this test because we want to ensure initial bounds stay the same + // on restore. + const workArea = screen.getPrimaryDisplay().workArea; + const defaultBounds = { width: 100, height: 100, x: workArea.x, y: workArea.y }; + const initialWindow = new BrowserWindow({ + ...defaultBounds, + show: false, + name: windowName, + windowStatePersistence: true + }); + const enterFullScreen = once(initialWindow, 'enter-full-screen'); + initialWindow.setFullScreen(true); + if (!initialWindow.isFullScreen()) await enterFullScreen; + + initialWindow.destroy(); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: { + displayMode: false + }, + show: false + }); + // We expect the bounds to restore to the values same as before the fullscreen state was set + expectBoundsEqual(w.getBounds(), defaultBounds); + expect(w.isFullScreen()).to.equal(false); + + w.destroy(); + }); + + it('should restore display modes when bounds is disabled', async () => { + await createAndSaveWindowState(preferencesPath, windowName, { fullscreen: true }); + await setTimeout(2000); + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: { + bounds: false + } + }); + + const enterFullScreen = once(w, 'enter-full-screen'); + if (!w.isFullScreen()) await enterFullScreen; + expect(w.isFullScreen()).to.equal(true); + + w.destroy(); + }); + + it('should respect fullscreenable property', async () => { + await createAndSaveWindowState(preferencesPath, windowName, { fullscreen: true }); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + fullscreenable: false, + show: false + }); + // Wait for the window to potentially enter fullscreen + await setTimeout(2000); + + expect(w.isFullScreen()).to.equal(false); + expect(w.isFullScreenable()).to.equal(false); + + w.destroy(); + }); + + it('should respect minWidth and minHeight properly', async () => { + await createAndSaveWindowState(preferencesPath, windowName, { width: 200, height: 200 }); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + minWidth: 400, + minHeight: 400, + show: false + }); + + const bounds = w.getBounds(); + expect(bounds.width).to.be.at.least(400); + expect(bounds.height).to.be.at.least(400); + }); + + it('should respect maxWidth and maxHeight properly', async () => { + await createAndSaveWindowState(preferencesPath, windowName, { width: 800, height: 800 }); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + maxWidth: 400, + maxHeight: 400, + show: false + }); + + const bounds = w.getBounds(); + expect(bounds.width).to.be.at.most(400); + expect(bounds.height).to.be.at.most(400); + }); + + it('should restore correct state for each named window independently - multi window check', async () => { + const window1Name = 'test-window-1'; + const window2Name = 'test-window-2'; + + // Clear any existing state + BrowserWindow.clearPersistedState(window1Name); + BrowserWindow.clearPersistedState(window2Name); + + const workArea = screen.getPrimaryDisplay().workArea; + + const bounds1 = { width: 100, height: 100, x: workArea.x, y: workArea.y }; + const w1 = new BrowserWindow({ + name: window1Name, + windowStatePersistence: true, + ...bounds1, + show: false + }); + + w1.destroy(); + + const bounds2 = { width: 120, height: 100, x: workArea.x, y: workArea.y }; + const w2 = new BrowserWindow({ + name: window2Name, + windowStatePersistence: true, + ...bounds2, + show: false + }); + + w2.destroy(); + + if (!fs.existsSync(preferencesPath)) { + // File doesn't exist, wait for creation + await waitForPrefsFileCreation(preferencesPath); + } else { + // File exists, wait for update + const initialModTime = getPrefsModTime(preferencesPath); + await waitForPrefsUpdate(initialModTime, preferencesPath); + } + + const restored1 = new BrowserWindow({ + name: window1Name, + windowStatePersistence: true, + show: false + }); + + const restored2 = new BrowserWindow({ + name: window2Name, + windowStatePersistence: true, + show: false + }); + + expectBoundsEqual(restored1.getBounds(), bounds1); + expectBoundsEqual(restored2.getBounds(), bounds2); + + restored1.destroy(); + restored2.destroy(); + }); + + it('should adjust restored bounds if they overflow current work area entirely', async () => { + const workArea = screen.getPrimaryDisplay().workArea; + // Completely off-screen to the right + const offscreenBounds = { + width: 100, + height: 100, + x: workArea.x + workArea.width + 10, + y: workArea.y + workArea.height + 10 + }; + + await createAndSaveWindowState(preferencesPath, windowName, offscreenBounds); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + const bounds = w.getBounds(); + + // Window should be moved to be entirely visible + expect(bounds.x + bounds.width).to.be.at.most(workArea.x + workArea.width); + expect(bounds.y + bounds.height).to.be.at.most(workArea.y + workArea.height); + expect(bounds.x).to.be.at.least(workArea.x); + expect(bounds.y).to.be.at.least(workArea.y); + expect(bounds.width).to.equal(100); + expect(bounds.height).to.equal(100); + + w.destroy(); + }); + + ifit(process.platform === 'darwin')( + 'should adjust bounds if window overflows work area such that the window is entirely visible', + async () => { + const workArea = screen.getPrimaryDisplay().workArea; + const overflowBounds = { + width: 100, + height: 100, + x: workArea.x + workArea.width - 20, + y: workArea.y + workArea.height - 20 + }; + + await createAndSaveWindowState(preferencesPath, windowName, overflowBounds); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + const bounds = w.getBounds(); + + // On macOS, window should be adjusted to be entirely visible + expect(bounds.x + bounds.width).to.be.at.most(workArea.x + workArea.width); + expect(bounds.y + bounds.height).to.be.at.most(workArea.y + workArea.height); + expect(bounds.width).to.equal(100); + expect(bounds.height).to.equal(100); + + w.destroy(); + } + ); + + ifit(process.platform !== 'darwin')( + 'should adjust bounds if window overflows work area such that the window has minimum visible height/width 100x100', + async () => { + const workArea = screen.getPrimaryDisplay().workArea; + // Initialize with 50x50 of the window visible + const overflowBounds = { + width: 120, + height: 120, + x: workArea.x + workArea.width - 50, + y: workArea.y + workArea.height - 50 + }; + + await createAndSaveWindowState(preferencesPath, windowName, overflowBounds); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + const bounds = w.getBounds(); + // Calculate the boundaries of the visible intersection rectangle + const leftMost = Math.max(bounds.x, workArea.x); + const rightMost = Math.min(bounds.x + bounds.width, workArea.x + workArea.width); + const topMost = Math.max(bounds.y, workArea.y); + const bottomMost = Math.min(bounds.y + bounds.height, workArea.y + workArea.height); + // On non-macOS platforms, at least 100x100 should be visible + const visibleWidth = rightMost - leftMost; + const visibleHeight = bottomMost - topMost; + + expect(visibleWidth).to.be.at.least(100); + expect(visibleHeight).to.be.at.least(100); + expect(bounds.width).to.equal(120); + expect(bounds.height).to.equal(120); + + w.destroy(); + } + ); + + it('should respect show:false when restoring display modes', async () => { + await createAndSaveWindowState(preferencesPath, windowName, { fullscreen: true }); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + const shown = once(w, 'show'); + const enterFullScreen = once(w, 'enter-full-screen'); + + await setTimeout(2000); + expect(w.isVisible()).to.equal(false); + + w.show(); + await shown; + expect(w.isVisible()).to.equal(true); + + // Fullscreen state should still be restored correctly + if (!w.isFullScreen()) await enterFullScreen; + expect(w.isFullScreen()).to.equal(true); + + w.destroy(); + }); + + it('should restore display modes when shown via showInactive()', async () => { + await createAndSaveWindowState(preferencesPath, windowName, { fullscreen: true }); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + const enterFullScreen = once(w, 'enter-full-screen'); + + await setTimeout(2000); + expect(w.isVisible()).to.equal(false); + + // showInactive() should flush the deferred display-mode restore just + // like show() does. + w.showInactive(); + + if (!w.isFullScreen()) await enterFullScreen; + expect(w.isFullScreen()).to.equal(true); + + w.destroy(); + }); + + it('should save state when destroyed before being shown', async () => { + await createAndSaveWindowState(preferencesPath, windowName, { width: 400, height: 300 }); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + // Mutate bounds while the window is still hidden and never shown. + const newBounds = { x: 100, y: 150, width: 500, height: 450 }; + w.setBounds(newBounds); + + const initialModTime = getPrefsModTime(preferencesPath); + + // Destroying without ever calling show()/showInactive() must still + // flush the latest state to disk (the restore guard must not suppress + // the close-time save). + w.destroy(); + + await waitForPrefsUpdate(initialModTime, preferencesPath); + + const savedState = getWindowStateFromDisk(windowName, preferencesPath); + expect(savedState).to.not.be.null(); + expect(savedState.right - savedState.left).to.equal(newBounds.width); + expect(savedState.bottom - savedState.top).to.equal(newBounds.height); + }); + }); + + // FIXME(nilayarya): Figure out why these tests fail on macOS-x64 + // virtualDisplay.create() is creating double displays on macOS-x64. + // Skip on CI: the GitHub Actions macOS runners lack the + // permissions/entitlements needed for CGVirtualDisplay to register + // and position a virtual display, so these tests can only run on a + // developer machine. + const testMultiMonitor = + process.platform === 'darwin' && + process.arch === 'arm64' && + screen.getAllDisplays().length === 1 && + !process.env.CI; + + ifdescribe(testMultiMonitor)('multi-monitor tests', () => { + const virtualDisplay = require('@electron-ci/virtual-display'); + const primaryDisplay = screen.getPrimaryDisplay(); + + function createDisplay(opts: { width: number; height: number; x: number; y: number }): number { + let lastError: Error = new Error('Failed to create virtual display'); + for (let attempt = 0; attempt < 3; attempt++) { + try { + return virtualDisplay.create(opts); + } catch (e) { + lastError = e as Error; + } + } + throw lastError; + } + + async function waitForDisplayPositioned(expectedX: number, expectedY: number, expectedCount?: number) { + const count = expectedCount ?? screen.getAllDisplays().length; + for (let i = 0; i < 30; i++) { + if (screen.getAllDisplays().length >= count) { + const display = screen.getDisplayNearestPoint({ x: expectedX, y: expectedY }); + if (display.bounds.x === expectedX && display.bounds.y === expectedY) { + return display; + } + } + await setTimeout(500); + } + const display = screen.getDisplayNearestPoint({ x: expectedX, y: expectedY }); + expect(display.bounds.x).to.equal(expectedX, 'Virtual display did not reach expected x position'); + return display; + } + + beforeEach(async () => { + virtualDisplay.forceCleanup(); + let attempts = 0; + while (screen.getAllDisplays().length > 1 && attempts++ < 20) await setTimeout(1000); + const displayCount = screen.getAllDisplays().length; + // We expect only the primary display to be present + expect(displayCount).to.equal(1, `Display cleanup failed: ${displayCount} displays remain`); + }); + + it('should restore window bounds correctly on a secondary display', async () => { + const targetDisplayX = primaryDisplay.bounds.x + primaryDisplay.bounds.width; + const targetDisplayY = primaryDisplay.bounds.y; + + // Create a new virtual target display to the right of the primary display + const targetDisplayId = createDisplay({ + width: 1920, + height: 1080, + x: targetDisplayX, + y: targetDisplayY + }); + const targetDisplay = await waitForDisplayPositioned(targetDisplayX, targetDisplayY, 2); + expect(targetDisplay.bounds.width).to.equal(1920); + expect(targetDisplay.bounds.height).to.equal(1080); + + // Bounds for the test window on the virtual target display + const boundsOnTargetDisplay = { + width: 400, + height: 300, + x: targetDisplay.workArea.x + 100, + y: targetDisplay.workArea.y + 100 + }; + + await createAndSaveWindowState(preferencesPath, windowName, boundsOnTargetDisplay); + + // Restore the window state by creating a new window with the same name + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + const restoredBounds = w.getBounds(); + expectBoundsEqual(restoredBounds, boundsOnTargetDisplay); + + w.destroy(); + virtualDisplay.destroy(targetDisplayId); + }); + + it('should restore window to a visible location when saved display no longer exists', async () => { + const targetDisplayX = primaryDisplay.bounds.x + primaryDisplay.bounds.width; + const targetDisplayY = primaryDisplay.bounds.y; + + // Create a new virtual target display to the right of the primary display + const targetDisplayId = createDisplay({ + width: 1920, + height: 1080, + x: targetDisplayX, + y: targetDisplayY + }); + + const targetDisplay = await waitForDisplayPositioned(targetDisplayX, targetDisplayY, 2); + + // Bounds for the test window on the virtual target display + const boundsOnTargetDisplay = { + width: 400, + height: 300, + x: targetDisplay.workArea.x + 100, + y: targetDisplay.workArea.y + 100 + }; + + // Save window state on the virtual display + await createAndSaveWindowState(preferencesPath, windowName, boundsOnTargetDisplay); + + virtualDisplay.destroy(targetDisplayId); + // Wait for the target virtual display to be destroyed + while (screen.getAllDisplays().length > 1) await setTimeout(1000); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + const restoredBounds = w.getBounds(); + const primaryWorkArea = primaryDisplay.workArea; + + // Window should be fully visible on the primary display + expect(restoredBounds.x).to.be.at.least(primaryWorkArea.x); + expect(restoredBounds.y).to.be.at.least(primaryWorkArea.y); + expect(restoredBounds.x + restoredBounds.width).to.be.at.most(primaryWorkArea.x + primaryWorkArea.width); + expect(restoredBounds.y + restoredBounds.height).to.be.at.most(primaryWorkArea.y + primaryWorkArea.height); + + // Window should maintain its original size + expect(restoredBounds.width).to.equal(boundsOnTargetDisplay.width); + expect(restoredBounds.height).to.equal(boundsOnTargetDisplay.height); + + w.destroy(); + }); + + it('should fallback to nearest display when saved display no longer exists', async () => { + const targetDisplayX = primaryDisplay.bounds.x + primaryDisplay.bounds.width; + const targetDisplayY = primaryDisplay.bounds.y; + + // Create first virtual display to the right of primary + const middleDisplayId = createDisplay({ + width: 1920, + height: 1080, + x: targetDisplayX, + y: targetDisplayY + }); + + // Create second virtual display to the right of the first (rightmost) + const rightmostDisplayX = targetDisplayX + 1920; + const rightmostDisplayId = createDisplay({ + width: 1920, + height: 1080, + x: rightmostDisplayX, + y: targetDisplayY + }); + + const middleDisplay = await waitForDisplayPositioned(targetDisplayX, targetDisplayY, 3); + const rightmostDisplay = await waitForDisplayPositioned(rightmostDisplayX, targetDisplayY, 3); + + // Bounds for the test window on the rightmost display + const boundsOnRightmostDisplay = { + width: 400, + height: 300, + x: rightmostDisplay.workArea.x + 100, + y: rightmostDisplay.workArea.y + 100 + }; + + // Save window state on the rightmost display + await createAndSaveWindowState(preferencesPath, windowName, boundsOnRightmostDisplay); + + // Destroy the rightmost display (where window was saved) + virtualDisplay.destroy(rightmostDisplayId); + // Wait for the rightmost display to be destroyed + while (screen.getAllDisplays().length > 2) await setTimeout(1000); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + const restoredBounds = w.getBounds(); + + // Window should be restored on the middle display (nearest remaining display) + expect(restoredBounds.x).to.be.at.least(middleDisplay.workArea.x); + expect(restoredBounds.y).to.be.at.least(middleDisplay.workArea.y); + expect(restoredBounds.x + restoredBounds.width).to.be.at.most( + middleDisplay.workArea.x + middleDisplay.workArea.width + ); + expect(restoredBounds.y + restoredBounds.height).to.be.at.most( + middleDisplay.workArea.y + middleDisplay.workArea.height + ); + + // Window should maintain its original size + expect(restoredBounds.width).to.equal(boundsOnRightmostDisplay.width); + expect(restoredBounds.height).to.equal(boundsOnRightmostDisplay.height); + + w.destroy(); + virtualDisplay.destroy(middleDisplayId); + }); + + it('should restore multiple named windows independently across displays', async () => { + const targetDisplayX = primaryDisplay.bounds.x + primaryDisplay.bounds.width; + const targetDisplayY = primaryDisplay.bounds.y; + + // Create a first virtual display to the right of the primary display + const targetDisplayId1 = createDisplay({ + width: 1920, + height: 1080, + x: targetDisplayX, + y: targetDisplayY + }); + // Create a second virtual display to the right of the first + const targetDisplayId2 = createDisplay({ + width: 1600, + height: 900, + x: targetDisplayX + 1920, + y: targetDisplayY + }); + + const targetDisplay1 = await waitForDisplayPositioned(targetDisplayX, targetDisplayY, 3); + const targetDisplay2 = await waitForDisplayPositioned(targetDisplayX + 1920, targetDisplayY, 3); + + // Window 1 on primary display + const window1Name = 'test-multi-window-1'; + const bounds1 = { + width: 300, + height: 200, + x: primaryDisplay.workArea.x + 50, + y: primaryDisplay.workArea.y + 50 + }; + + // Window 2 on second display (first virtual) + const window2Name = 'test-multi-window-2'; + const bounds2 = { + width: 400, + height: 300, + x: targetDisplay1.workArea.x + 100, + y: targetDisplay1.workArea.y + 100 + }; + + // Window 3 on third display (second virtual) + const window3Name = 'test-multi-window-3'; + const bounds3 = { + width: 350, + height: 250, + x: targetDisplay2.workArea.x + 150, + y: targetDisplay2.workArea.y + 150 + }; + + // Clear window state for all three windows from previous tests + BrowserWindow.clearPersistedState(window1Name); + BrowserWindow.clearPersistedState(window2Name); + BrowserWindow.clearPersistedState(window3Name); + + // Create and save state for all three windows + const w1 = new BrowserWindow({ + name: window1Name, + windowStatePersistence: true, + show: false, + ...bounds1 + }); + const w2 = new BrowserWindow({ + name: window2Name, + windowStatePersistence: true, + show: false, + ...bounds2 + }); + const w3 = new BrowserWindow({ + name: window3Name, + windowStatePersistence: true, + show: false, + ...bounds3 + }); + + w1.destroy(); + w2.destroy(); + w3.destroy(); + + await setTimeout(2000); + + // Restore all three windows + const restoredW1 = new BrowserWindow({ + name: window1Name, + windowStatePersistence: true, + show: false + }); + const restoredW2 = new BrowserWindow({ + name: window2Name, + windowStatePersistence: true, + show: false + }); + const restoredW3 = new BrowserWindow({ + name: window3Name, + windowStatePersistence: true, + show: false + }); + + // Check that each window restored to its correct display and position + expectBoundsEqual(restoredW1.getBounds(), bounds1); + expectBoundsEqual(restoredW2.getBounds(), bounds2); + expectBoundsEqual(restoredW3.getBounds(), bounds3); + + restoredW1.destroy(); + restoredW2.destroy(); + restoredW3.destroy(); + virtualDisplay.destroy(targetDisplayId1); + virtualDisplay.destroy(targetDisplayId2); + }); + + it('should restore fullscreen state on correct display', async () => { + const targetDisplayX = primaryDisplay.bounds.x + primaryDisplay.bounds.width; + const targetDisplayY = primaryDisplay.bounds.y; + + // Create a new virtual target display to the right of the primary display + const targetDisplayId = createDisplay({ + width: 1920, + height: 1080, + x: targetDisplayX, + y: targetDisplayY + }); + + const targetDisplay = await waitForDisplayPositioned(targetDisplayX, targetDisplayY, 2); + + // Create window on target display and set fullscreen + const initialBounds = { + width: 400, + height: 300, + x: targetDisplay.workArea.x + 100, + y: targetDisplay.workArea.y + 100, + fullscreen: true + }; + + await createAndSaveWindowState(preferencesPath, windowName, initialBounds); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true + }); + + const enterFullScreen = once(w, 'enter-full-screen'); + if (!w.isFullScreen()) await enterFullScreen; + + expect(w.isFullScreen()).to.equal(true); + + // Check that fullscreen window is on the correct display + const fsBounds = w.getBounds(); + expect(fsBounds.x).to.be.at.least(targetDisplay.bounds.x); + expect(fsBounds.y).to.be.at.least(targetDisplay.bounds.y); + expect(fsBounds.x + fsBounds.width).to.be.at.most(targetDisplay.bounds.x + targetDisplay.bounds.width); + expect(fsBounds.y + fsBounds.height).to.be.at.most(targetDisplay.bounds.y + targetDisplay.bounds.height); + + w.destroy(); + virtualDisplay.destroy(targetDisplayId); + }); + + it('should restore maximized state on correct display', async () => { + const targetDisplayX = primaryDisplay.bounds.x + primaryDisplay.bounds.width; + const targetDisplayY = primaryDisplay.bounds.y; + + // Create a new virtual target display to the right of the primary display + const targetDisplayId = createDisplay({ + width: 1920, + height: 1080, + x: targetDisplayX, + y: targetDisplayY + }); + + const targetDisplay = await waitForDisplayPositioned(targetDisplayX, targetDisplayY, 2); + + // Create window on target display and maximize it + const w1 = new BrowserWindow({ + name: windowName, + windowStatePersistence: { + displayMode: false + }, + x: targetDisplay.workArea.x, + y: targetDisplay.workArea.y + }); + + const maximized = once(w1, 'maximize'); + w1.maximize(); + if (!w1.isMaximized()) await maximized; + + w1.destroy(); + await setTimeout(2000); + + const w2 = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: true + }); + + const maximized_ = once(w2, 'maximize'); + if (!w2.isMaximized()) await maximized_; + + expect(w2.isMaximized()).to.equal(true); + // Check that maximized window is on the correct display + const maximizedBounds = w2.getBounds(); + expect(maximizedBounds.x).to.be.at.least(targetDisplay.bounds.x); + expect(maximizedBounds.y).to.be.at.least(targetDisplay.bounds.y); + expect(maximizedBounds.x + maximizedBounds.width).to.be.at.most( + targetDisplay.bounds.x + targetDisplay.bounds.width + ); + expect(maximizedBounds.y + maximizedBounds.height).to.be.at.most( + targetDisplay.bounds.y + targetDisplay.bounds.height + ); + + w2.destroy(); + virtualDisplay.destroy(targetDisplayId); + }); + + it('should restore kiosk state on correct display', async () => { + const targetDisplayX = primaryDisplay.bounds.x + primaryDisplay.bounds.width; + const targetDisplayY = primaryDisplay.bounds.y; + + // Create a new virtual target display to the right of the primary display + const targetDisplayId = createDisplay({ + width: 1920, + height: 1080, + x: targetDisplayX, + y: targetDisplayY + }); + + const targetDisplay = await waitForDisplayPositioned(targetDisplayX, targetDisplayY, 2); + + // Create window on target display and set kiosk: true + const initialBounds = { + width: 400, + height: 300, + x: targetDisplay.workArea.x + 100, + y: targetDisplay.workArea.y + 100, + kiosk: true + }; + + await createAndSaveWindowState(preferencesPath, windowName, initialBounds); + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true + }); + + const enterFullScreen = once(w, 'enter-full-screen'); + if (!w.isFullScreen()) await enterFullScreen; + + expect(w.isFullScreen()).to.equal(true); + expect(w.isKiosk()).to.equal(true); + + // Check that kiosk window is on the correct display + const kioskBounds = w.getBounds(); + expect(kioskBounds.x).to.be.at.least(targetDisplay.bounds.x); + expect(kioskBounds.y).to.be.at.least(targetDisplay.bounds.y); + expect(kioskBounds.x + kioskBounds.width).to.be.at.most(targetDisplay.bounds.x + targetDisplay.bounds.width); + expect(kioskBounds.y + kioskBounds.height).to.be.at.most( + targetDisplay.bounds.y + targetDisplay.bounds.height + ); + + w.destroy(); + virtualDisplay.destroy(targetDisplayId); + }); + + it('should maintain same bounds when target display resolution increases', async () => { + const targetDisplayX = primaryDisplay.bounds.x + primaryDisplay.bounds.width; + const targetDisplayY = primaryDisplay.bounds.y; + + // Create initial virtual display + const targetDisplayId = createDisplay({ + width: 1920, + height: 1080, + x: targetDisplayX, + y: targetDisplayY + }); + + const targetDisplay = await waitForDisplayPositioned(targetDisplayX, targetDisplayY, 2); + + // Create a new virtual display with double the resolution of the target display + const higherResDisplayId = createDisplay({ + width: targetDisplay.bounds.width * 2, + height: targetDisplay.bounds.height * 2, + x: targetDisplayX + targetDisplay.bounds.width, + y: targetDisplayY + }); + + await waitForDisplayPositioned(targetDisplayX + targetDisplay.bounds.width, targetDisplayY, 3); + + // Bounds for the test window on the virtual target display + const initialBounds = { + width: 400, + height: 300, + x: targetDisplay.workArea.x + 100, + y: targetDisplay.workArea.y + 100 + }; + + await createAndSaveWindowState(preferencesPath, windowName, initialBounds); + + // Destroy the target display and wait for the higher resolution display to take its place + virtualDisplay.destroy(targetDisplayId); + while (screen.getAllDisplays().length > 2) await setTimeout(1000); + + // Restore window + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + const restoredBounds = w.getBounds(); + + // Window should maintain same x, y, width, height as the display got bigger + expectBoundsEqual(restoredBounds, initialBounds); + + w.destroy(); + virtualDisplay.destroy(higherResDisplayId); + }); + + it('should reposition and resize window when target display resolution decreases', async () => { + const targetDisplayX = primaryDisplay.bounds.x + primaryDisplay.bounds.width; + const targetDisplayY = primaryDisplay.bounds.y; + // Create initial virtual display with high resolution + const targetDisplayId = createDisplay({ + width: 2560, + height: 1440, + x: targetDisplayX, + y: targetDisplayY + }); + + const targetDisplay = await waitForDisplayPositioned(targetDisplayX, targetDisplayY, 2); + + // Create a new virtual display with half the resolution of the target display shifted down + const lowerResDisplayId = createDisplay({ + width: targetDisplay.bounds.width / 2, + height: targetDisplay.bounds.height / 2, + x: targetDisplayX + targetDisplay.bounds.width, + y: targetDisplay.bounds.height / 2 + }); + + await waitForDisplayPositioned( + targetDisplayX + targetDisplay.bounds.width, + targetDisplay.bounds.height / 2, + 3 + ); + + // Bounds that would overflow on a smaller display + const initialBounds = { + x: targetDisplay.workArea.x, + y: targetDisplay.workArea.y, + width: targetDisplay.bounds.width, + height: targetDisplay.bounds.height + }; + + await createAndSaveWindowState(preferencesPath, windowName, initialBounds); + + // Destroy and and wait for the lower resolution display to take its place + virtualDisplay.destroy(targetDisplayId); + while (screen.getAllDisplays().length > 2) await setTimeout(1000); + + // We expect the display to be shifted down as we set y: targetDisplay.bounds.height / 2 earlier + const smallerDisplay = screen.getDisplayNearestPoint({ + x: targetDisplayX, + y: targetDisplay.bounds.height / 2 + }); + + // Restore window + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + const restoredBounds = w.getBounds(); + + // Window should be repositioned to be entirely visible on smaller display + expect(restoredBounds.x).to.be.at.least(smallerDisplay.workArea.x); + expect(restoredBounds.y).to.be.at.least(smallerDisplay.workArea.y); + expect(restoredBounds.x + restoredBounds.width).to.be.at.most( + smallerDisplay.workArea.x + smallerDisplay.workArea.width + ); + expect(restoredBounds.y + restoredBounds.height).to.be.at.most( + smallerDisplay.workArea.y + smallerDisplay.workArea.height + ); + + w.destroy(); + virtualDisplay.destroy(lowerResDisplayId); + }); + }); + }); + describe('event emitters', () => { + const preferencesPath = path.join(app.getPath('userData'), 'Local State'); + const windowName = 'test-restore-window'; + + it('should emit persisted-state-restored when windowStatePersistence is enabled and state exists', async () => { + await createAndSaveWindowState(preferencesPath, windowName, { width: 300, height: 200 }); + + const restoredPromise = new Promise<void>((resolve) => { + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + w.once('persisted-state-restored', () => { + resolve(); + w.destroy(); + }); + }); + + await restoredPromise; + }); + + it('should not emit persisted-state-restored when windowStatePersistence is disabled', async () => { + await createAndSaveWindowState(preferencesPath, windowName, { width: 300, height: 200 }); + + let eventEmitted = false; + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: false, + show: false + }); + + w.on('persisted-state-restored', () => { + eventEmitted = true; + }); + + // Wait for the event to be emitted for 5 seconds + await setTimeout(5000); + + expect(eventEmitted).to.equal(false); + w.destroy(); + }); + + it('should not emit persisted-state-restored when no window state exists on disk', async () => { + // Clear any existing state to ensure no state exists + BrowserWindow.clearPersistedState(windowName); + + let eventEmitted = false; + + const w = new BrowserWindow({ + name: windowName, + windowStatePersistence: true, + show: false + }); + + w.on('persisted-state-restored', () => { + eventEmitted = true; + }); + + // Wait for the event to be emitted for 5 seconds + await setTimeout(5000); + + expect(eventEmitted).to.equal(false); + w.destroy(); + }); + }); + }); }); diff --git a/spec/fixtures/api/window-state-save/close-save/index.js b/spec/fixtures/api/window-state-save/close-save/index.js new file mode 100644 index 0000000000..6dcd5e935f --- /dev/null +++ b/spec/fixtures/api/window-state-save/close-save/index.js @@ -0,0 +1,27 @@ +const { app, BrowserWindow } = require('electron'); + +const os = require('node:os'); +const path = require('node:path'); + +const sharedUserData = path.join(os.tmpdir(), 'electron-window-state-test'); +app.setPath('userData', sharedUserData); + +app.whenReady().then(() => { + const w = new BrowserWindow({ + width: 400, + height: 300, + name: 'test-close-save', + windowStatePersistence: true, + show: false + }); + + w.on('close', () => { + app.quit(); + }); + + w.close(); + // Timeout of 10s to ensure app exits + setTimeout(() => { + app.quit(); + }, 10000); +}); diff --git a/spec/fixtures/api/window-state-save/fullscreen-save/index.js b/spec/fixtures/api/window-state-save/fullscreen-save/index.js new file mode 100644 index 0000000000..bd2f2cbabb --- /dev/null +++ b/spec/fixtures/api/window-state-save/fullscreen-save/index.js @@ -0,0 +1,28 @@ +const { app, BrowserWindow } = require('electron'); + +const os = require('node:os'); +const path = require('node:path'); + +const sharedUserData = path.join(os.tmpdir(), 'electron-window-state-test'); +app.setPath('userData', sharedUserData); + +app.whenReady().then(() => { + const w = new BrowserWindow({ + width: 400, + height: 300, + name: 'test-fullscreen-save', + windowStatePersistence: true + }); + + w.on('enter-full-screen', () => { + setTimeout(() => { + app.quit(); + }, 1000); + }); + + w.setFullScreen(true); + // Timeout of 10s to ensure app exits + setTimeout(() => { + app.quit(); + }, 10000); +}); diff --git a/spec/fixtures/api/window-state-save/kiosk-save/index.js b/spec/fixtures/api/window-state-save/kiosk-save/index.js new file mode 100644 index 0000000000..29f3b5f167 --- /dev/null +++ b/spec/fixtures/api/window-state-save/kiosk-save/index.js @@ -0,0 +1,28 @@ +const { app, BrowserWindow } = require('electron'); + +const os = require('node:os'); +const path = require('node:path'); + +const sharedUserData = path.join(os.tmpdir(), 'electron-window-state-test'); +app.setPath('userData', sharedUserData); + +app.whenReady().then(() => { + const w = new BrowserWindow({ + width: 400, + height: 300, + name: 'test-kiosk-save', + windowStatePersistence: true + }); + + w.on('enter-full-screen', () => { + setTimeout(() => { + app.quit(); + }, 1000); + }); + + w.setKiosk(true); + // Timeout of 10s to ensure app exits + setTimeout(() => { + app.quit(); + }, 10000); +}); diff --git a/spec/fixtures/api/window-state-save/main-thread-busy/index.js b/spec/fixtures/api/window-state-save/main-thread-busy/index.js new file mode 100644 index 0000000000..bb41020823 --- /dev/null +++ b/spec/fixtures/api/window-state-save/main-thread-busy/index.js @@ -0,0 +1,41 @@ +const { app, BrowserWindow } = require('electron'); + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const sharedUserData = path.join(os.tmpdir(), 'electron-window-state-test'); +app.setPath('userData', sharedUserData); +const prefsPath = path.join(sharedUserData, 'Local State'); + +const waitForPrefsFile = async () => { + while (!fs.existsSync(prefsPath)) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } +}; + +app.whenReady().then(async () => { + const w = new BrowserWindow({ + width: 400, + height: 300, + name: 'test-main-thread-busy', + windowStatePersistence: true, + show: false + }); + + await waitForPrefsFile(); + const initialMtime = fs.statSync(prefsPath).mtimeMs; + + const moved = new Promise((resolve) => w.once('move', resolve)); + w.setPosition(100, 100); + await moved; + + const startTime = Date.now(); + while (Date.now() - startTime < 25_000); + + const finalMtime = fs.statSync(prefsPath).mtimeMs; + const exitCode = finalMtime === initialMtime ? 0 : 1; + + w.destroy(); + app.exit(exitCode); +}); diff --git a/spec/fixtures/api/window-state-save/maximize-save/index.js b/spec/fixtures/api/window-state-save/maximize-save/index.js new file mode 100644 index 0000000000..3e0dcd8915 --- /dev/null +++ b/spec/fixtures/api/window-state-save/maximize-save/index.js @@ -0,0 +1,28 @@ +const { app, BrowserWindow } = require('electron'); + +const os = require('node:os'); +const path = require('node:path'); + +const sharedUserData = path.join(os.tmpdir(), 'electron-window-state-test'); +app.setPath('userData', sharedUserData); + +app.whenReady().then(() => { + const w = new BrowserWindow({ + width: 400, + height: 300, + name: 'test-maximize-save', + windowStatePersistence: true + }); + + w.on('maximize', () => { + setTimeout(() => { + app.quit(); + }, 1000); + }); + + w.maximize(); + // Timeout of 10s to ensure app exits + setTimeout(() => { + app.quit(); + }, 10000); +}); diff --git a/spec/fixtures/api/window-state-save/minimize-save/index.js b/spec/fixtures/api/window-state-save/minimize-save/index.js new file mode 100644 index 0000000000..36c7018abb --- /dev/null +++ b/spec/fixtures/api/window-state-save/minimize-save/index.js @@ -0,0 +1,28 @@ +const { app, BrowserWindow } = require('electron'); + +const os = require('node:os'); +const path = require('node:path'); + +const sharedUserData = path.join(os.tmpdir(), 'electron-window-state-test'); +app.setPath('userData', sharedUserData); + +app.whenReady().then(() => { + const w = new BrowserWindow({ + width: 400, + height: 300, + name: 'test-minimize-save', + windowStatePersistence: true + }); + + w.on('minimize', () => { + setTimeout(() => { + app.quit(); + }, 1000); + }); + + w.minimize(); + // Timeout of 10s to ensure app exits + setTimeout(() => { + app.quit(); + }, 10000); +}); diff --git a/spec/fixtures/api/window-state-save/move-save/index.js b/spec/fixtures/api/window-state-save/move-save/index.js new file mode 100644 index 0000000000..56fb079b32 --- /dev/null +++ b/spec/fixtures/api/window-state-save/move-save/index.js @@ -0,0 +1,23 @@ +const { app, BrowserWindow } = require('electron'); + +const os = require('node:os'); +const path = require('node:path'); + +const sharedUserData = path.join(os.tmpdir(), 'electron-window-state-test'); +app.setPath('userData', sharedUserData); + +app.whenReady().then(() => { + const w = new BrowserWindow({ + width: 400, + height: 300, + name: 'test-move-save', + windowStatePersistence: true, + show: false + }); + + w.setPosition(100, 150); + + setTimeout(() => { + app.quit(); + }, 1000); +}); diff --git a/spec/fixtures/api/window-state-save/resize-save/index.js b/spec/fixtures/api/window-state-save/resize-save/index.js new file mode 100644 index 0000000000..a25d399f65 --- /dev/null +++ b/spec/fixtures/api/window-state-save/resize-save/index.js @@ -0,0 +1,23 @@ +const { app, BrowserWindow } = require('electron'); + +const os = require('node:os'); +const path = require('node:path'); + +const sharedUserData = path.join(os.tmpdir(), 'electron-window-state-test'); +app.setPath('userData', sharedUserData); + +app.whenReady().then(async () => { + const w = new BrowserWindow({ + width: 400, + height: 300, + name: 'test-resize-save', + windowStatePersistence: true, + show: false + }); + + w.setSize(500, 400); + + setTimeout(() => { + app.quit(); + }, 1000); +}); diff --git a/spec/fixtures/api/window-state-save/schema-check/index.js b/spec/fixtures/api/window-state-save/schema-check/index.js new file mode 100644 index 0000000000..c0a3dbf8f8 --- /dev/null +++ b/spec/fixtures/api/window-state-save/schema-check/index.js @@ -0,0 +1,23 @@ +const { app, BrowserWindow } = require('electron'); + +const os = require('node:os'); +const path = require('node:path'); + +const sharedUserData = path.join(os.tmpdir(), 'electron-window-state-test'); +app.setPath('userData', sharedUserData); + +app.whenReady().then(() => { + const w = new BrowserWindow({ + width: 400, + height: 300, + name: 'test-window-state-schema', + windowStatePersistence: true, + show: true + }); + + w.close(); + + setTimeout(() => { + app.quit(); + }, 1000); +}); diff --git a/spec/fixtures/api/window-state-save/work-area-primary/index.js b/spec/fixtures/api/window-state-save/work-area-primary/index.js new file mode 100644 index 0000000000..4f7b0f86b7 --- /dev/null +++ b/spec/fixtures/api/window-state-save/work-area-primary/index.js @@ -0,0 +1,41 @@ +const { app, BrowserWindow, screen } = require('electron'); + +const os = require('node:os'); +const path = require('node:path'); + +const sharedUserData = path.join(os.tmpdir(), 'electron-window-state-test'); +app.setPath('userData', sharedUserData); + +app.whenReady().then(async () => { + const primaryDisplay = screen.getPrimaryDisplay(); + const workArea = primaryDisplay.workArea; + + const maxWidth = Math.max(200, Math.floor(workArea.width * 0.8)); + const maxHeight = Math.max(150, Math.floor(workArea.height * 0.8)); + const windowWidth = Math.min(400, maxWidth); + const windowHeight = Math.min(300, maxHeight); + + const w = new BrowserWindow({ + width: windowWidth, + height: windowHeight, + name: 'test-work-area-primary', + windowStatePersistence: true + }); + + // Center the window on the primary display to prevent overflow + const centerX = workArea.x + Math.floor((workArea.width - windowWidth) / 2); + const centerY = workArea.y + Math.floor((workArea.height - windowHeight) / 2); + + w.setPosition(centerX, centerY); + + w.on('close', () => { + app.quit(); + }); + + w.close(); + + // Timeout of 10s to ensure app exits + setTimeout(() => { + app.quit(); + }, 10000); +}); diff --git a/spec/fixtures/native-addon/virtual-display/binding.gyp b/spec/fixtures/native-addon/virtual-display/binding.gyp new file mode 100644 index 0000000000..aff671e317 --- /dev/null +++ b/spec/fixtures/native-addon/virtual-display/binding.gyp @@ -0,0 +1,34 @@ +{ + "targets": [{ + "target_name": "virtual_display", + "conditions": [ + ['OS=="mac"', { + "sources": [ + "src/addon.mm", + "src/VirtualDisplayBridge.m" + ], + "include_dirs": [ + "<!@(node -p \"require('node-addon-api').include\")", + "include" + ], + "dependencies": [ + "<!(node -p \"require('node-addon-api').gyp\")" + ], + "defines": [ + "NODE_ADDON_API_CPP_EXCEPTIONS" + ], + "cflags!": [ "-fno-exceptions" ], + "cflags_cc!": [ "-fno-exceptions" ], + "xcode_settings": { + "GCC_ENABLE_CPP_EXCEPTIONS": "YES", + "CLANG_ENABLE_OBJC_ARC": "YES", + "CLANG_CXX_LIBRARY": "libc++", + "MACOSX_DEPLOYMENT_TARGET": "11.0", + "OTHER_CFLAGS": [ + "-fobjc-arc" + ] + } + }] + ] + }] +} diff --git a/spec/fixtures/native-addon/virtual-display/include/VirtualDisplayBridge.h b/spec/fixtures/native-addon/virtual-display/include/VirtualDisplayBridge.h new file mode 100644 index 0000000000..dfb3096cf1 --- /dev/null +++ b/spec/fixtures/native-addon/virtual-display/include/VirtualDisplayBridge.h @@ -0,0 +1,121 @@ +#ifndef VirtualDisplayBridge_h +#define VirtualDisplayBridge_h + +#import <CoreGraphics/CoreGraphics.h> +#import <Foundation/Foundation.h> + +@interface VirtualDisplayBridge : NSObject + ++ (NSInteger)create:(int)width height:(int)height x:(int)x y:(int)y; ++ (BOOL)destroy:(NSInteger)displayId; ++ (BOOL)forceCleanup; + +@end + +@interface CGVirtualDisplay : NSObject { + unsigned int _vendorID; + unsigned int _productID; + unsigned int _serialNum; + NSString* _name; + struct CGSize _sizeInMillimeters; + unsigned int _maxPixelsWide; + unsigned int _maxPixelsHigh; + struct CGPoint _redPrimary; + struct CGPoint _greenPrimary; + struct CGPoint _bluePrimary; + struct CGPoint _whitePoint; + id _queue; + id _terminationHandler; + void* _client; + unsigned int _displayID; + unsigned int _hiDPI; + NSArray* _modes; + unsigned int _serverRPC_port; + unsigned int _proxyRPC_port; + unsigned int _clientHandler_port; +} + +@property(readonly, nonatomic) NSArray* modes; +@property(readonly, nonatomic) unsigned int hiDPI; +@property(readonly, nonatomic) unsigned int displayID; +@property(readonly, nonatomic) id terminationHandler; +@property(readonly, nonatomic) id queue; +@property(readonly, nonatomic) struct CGPoint whitePoint; +@property(readonly, nonatomic) struct CGPoint bluePrimary; +@property(readonly, nonatomic) struct CGPoint greenPrimary; +@property(readonly, nonatomic) struct CGPoint redPrimary; +@property(readonly, nonatomic) unsigned int maxPixelsHigh; +@property(readonly, nonatomic) unsigned int maxPixelsWide; +@property(readonly, nonatomic) struct CGSize sizeInMillimeters; +@property(readonly, nonatomic) NSString* name; +@property(readonly, nonatomic) unsigned int serialNum; +@property(readonly, nonatomic) unsigned int productID; +@property(readonly, nonatomic) unsigned int vendorID; +- (BOOL)applySettings:(id)arg1; +- (void)dealloc; +- (id)initWithDescriptor:(id)arg1; + +@end + +@interface CGVirtualDisplayDescriptor : NSObject { + unsigned int _vendorID; + unsigned int _productID; + unsigned int _serialNum; + NSString* _name; + struct CGSize _sizeInMillimeters; + unsigned int _maxPixelsWide; + unsigned int _maxPixelsHigh; + struct CGPoint _redPrimary; + struct CGPoint _greenPrimary; + struct CGPoint _bluePrimary; + struct CGPoint _whitePoint; + id _queue; + id _terminationHandler; +} + +@property(retain, nonatomic) id queue; +@property(retain, nonatomic) NSString* name; +@property(nonatomic) struct CGPoint whitePoint; +@property(nonatomic) struct CGPoint bluePrimary; +@property(nonatomic) struct CGPoint greenPrimary; +@property(nonatomic) struct CGPoint redPrimary; +@property(nonatomic) unsigned int maxPixelsHigh; +@property(nonatomic) unsigned int maxPixelsWide; +@property(nonatomic) struct CGSize sizeInMillimeters; +@property(nonatomic) unsigned int serialNum; +@property(nonatomic) unsigned int productID; +@property(nonatomic) unsigned int vendorID; +- (void)dealloc; +- (id)init; +@property(copy, nonatomic) id terminationHandler; + +@end + +@interface CGVirtualDisplayMode : NSObject { + unsigned int _width; + unsigned int _height; + double _refreshRate; +} + +@property(readonly, nonatomic) double refreshRate; +@property(readonly, nonatomic) unsigned int height; +@property(readonly, nonatomic) unsigned int width; +- (id)initWithWidth:(unsigned int)arg1 + height:(unsigned int)arg2 + refreshRate:(double)arg3; + +@end + +@interface CGVirtualDisplaySettings : NSObject { + NSArray* _modes; + unsigned int _hiDPI; +} + +@property(nonatomic) unsigned int hiDPI; +- (void)dealloc; +- (id)init; +@property(retain, nonatomic) NSArray* modes; + +@end + +#endif \ No newline at end of file diff --git a/spec/fixtures/native-addon/virtual-display/lib/virtual-display.js b/spec/fixtures/native-addon/virtual-display/lib/virtual-display.js new file mode 100644 index 0000000000..ffbc7f59cd --- /dev/null +++ b/spec/fixtures/native-addon/virtual-display/lib/virtual-display.js @@ -0,0 +1,7 @@ +module.exports = process.platform === 'darwin' + ? require('../build/Release/virtual_display.node') + : { + create: () => { throw new Error('Virtual displays only supported on macOS'); }, + destroy: () => { throw new Error('Virtual displays only supported on macOS'); }, + forceCleanup: () => { throw new Error('Virtual displays only supported on macOS'); } + }; diff --git a/spec/fixtures/native-addon/virtual-display/package.json b/spec/fixtures/native-addon/virtual-display/package.json new file mode 100644 index 0000000000..390df730fd --- /dev/null +++ b/spec/fixtures/native-addon/virtual-display/package.json @@ -0,0 +1,15 @@ +{ + "name": "@electron-ci/virtual-display", + "version": "1.0.0", + "description": "Virtual display for multi-monitor testing", + "main": "./lib/virtual-display.js", + "scripts": { + "install": "node-gyp configure && node-gyp build", + "clean": "rm -rf build", + "build-electron": "electron-rebuild" + }, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0" + } +} diff --git a/spec/fixtures/native-addon/virtual-display/src/VirtualDisplayBridge.m b/spec/fixtures/native-addon/virtual-display/src/VirtualDisplayBridge.m new file mode 100644 index 0000000000..24b3a27436 --- /dev/null +++ b/spec/fixtures/native-addon/virtual-display/src/VirtualDisplayBridge.m @@ -0,0 +1,145 @@ +#import "VirtualDisplayBridge.h" + +#include <unistd.h> + +static NSMutableDictionary<NSNumber*, CGVirtualDisplay*>* sDisplays; +static NSInteger sCounter = 0; + +@implementation VirtualDisplayBridge + ++ (void)initialize { + if (self == [VirtualDisplayBridge class]) { + sDisplays = [NSMutableDictionary new]; + } +} + ++ (NSInteger)create:(int)width height:(int)height x:(int)x y:(int)y { + int step = 1; + int minX = 720, minY = 720, maxX = 8192, maxY = 8192; + int minMultiplier = MAX((int)ceil((double)minX / (width * step)), + (int)ceil((double)minY / (height * step))); + int maxMultiplier = MIN((int)floor((double)maxX / (width * step)), + (int)floor((double)maxY / (height * step))); + + CGVirtualDisplayDescriptor* descriptor = [[CGVirtualDisplayDescriptor alloc] init]; + if (!descriptor) + return 0; + + descriptor.queue = + dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0); + descriptor.name = + [NSString stringWithFormat:@"Dummy %dx%d", width, height]; + descriptor.whitePoint = CGPointMake(0.950, 1.000); + descriptor.redPrimary = CGPointMake(0.454, 0.242); + descriptor.greenPrimary = CGPointMake(0.353, 0.674); + descriptor.bluePrimary = CGPointMake(0.157, 0.084); + descriptor.maxPixelsWide = (unsigned int)(width * step * maxMultiplier); + descriptor.maxPixelsHigh = (unsigned int)(height * step * maxMultiplier); + + double diagonal = (24.0 * 25.4) / + sqrt((double)(width * width) + (double)(height * height)); + descriptor.sizeInMillimeters = + CGSizeMake((double)width * diagonal, (double)height * diagonal); + + descriptor.serialNum = arc4random(); + descriptor.productID = + (unsigned int)(MIN(width - 1, 255) * 256 + MIN(height - 1, 255)); + descriptor.vendorID = 0xF0F0; + + CGVirtualDisplay* display = + [[CGVirtualDisplay alloc] initWithDescriptor:descriptor]; + if (!display) + return 0; + + NSMutableArray* modes = [NSMutableArray new]; + for (int mult = minMultiplier; mult <= maxMultiplier; mult++) { + unsigned int w = (unsigned int)(width * mult * step); + unsigned int h = (unsigned int)(height * mult * step); + CGVirtualDisplayMode* mode = + [[CGVirtualDisplayMode alloc] initWithWidth:w height:h refreshRate:60.0]; + if (mode) + [modes addObject:mode]; + } + + CGVirtualDisplaySettings* settings = [[CGVirtualDisplaySettings alloc] init]; + if (!settings) + return 0; + + settings.modes = modes; + settings.hiDPI = 0; + + if (![display applySettings:settings]) + return 0; + + CGDirectDisplayID displayID = [display displayID]; + if (![self waitForDisplayRegistration:displayID]) + return 0; + + [self positionDisplay:displayID x:x y:y]; + + sCounter++; + sDisplays[@(sCounter)] = display; + return sCounter; +} + ++ (BOOL)destroy:(NSInteger)displayId { + NSNumber* key = @(displayId); + if (!sDisplays[key]) + return NO; + [sDisplays removeObjectForKey:key]; + return YES; +} + ++ (BOOL)forceCleanup { + [sDisplays removeAllObjects]; + sCounter = 0; + + CGDisplayConfigRef config = NULL; + if (CGBeginDisplayConfiguration(&config) == kCGErrorSuccess) { + CGCompleteDisplayConfiguration(config, kCGConfigurePermanently); + } + + usleep(2000000); + + config = NULL; + if (CGBeginDisplayConfiguration(&config) == kCGErrorSuccess) { + CGCompleteDisplayConfiguration(config, kCGConfigureForSession); + } + + return YES; +} + +#pragma mark - Private + ++ (BOOL)waitForDisplayRegistration:(CGDirectDisplayID)targetID { + CGDirectDisplayID displays[32]; + uint32_t count = 0; + + for (int i = 0; i < 20; i++) { + if (CGGetActiveDisplayList(32, displays, &count) == kCGErrorSuccess) { + for (uint32_t j = 0; j < count; j++) { + if (displays[j] == targetID) + return YES; + } + } + usleep(100000); + } + return NO; +} + ++ (void)positionDisplay:(CGDirectDisplayID)displayID x:(int)x y:(int)y { + CGDisplayConfigRef config = NULL; + CGError err = CGBeginDisplayConfiguration(&config); + if (err != kCGErrorSuccess) + return; + + err = CGConfigureDisplayOrigin(config, displayID, (int32_t)x, (int32_t)y); + if (err != kCGErrorSuccess) { + CGCancelDisplayConfiguration(config); + return; + } + + CGCompleteDisplayConfiguration(config, kCGConfigurePermanently); +} + +@end diff --git a/spec/fixtures/native-addon/virtual-display/src/addon.mm b/spec/fixtures/native-addon/virtual-display/src/addon.mm new file mode 100644 index 0000000000..60285d1fd8 --- /dev/null +++ b/spec/fixtures/native-addon/virtual-display/src/addon.mm @@ -0,0 +1,197 @@ +#include <js_native_api.h> +#include <node_api.h> +#include "VirtualDisplayBridge.h" + +namespace { + +typedef struct { + const char* name; + int default_val; + int* ptr; +} PropertySpec; + +// Helper function to get an integer property from an object +bool GetIntProperty(napi_env env, + napi_value object, + const char* prop_name, + int* result, + int default_value) { + *result = default_value; + + bool has_prop; + if (napi_has_named_property(env, object, prop_name, &has_prop) != napi_ok || + !has_prop) { + return true; + } + + napi_value prop_value; + if (napi_get_named_property(env, object, prop_name, &prop_value) != napi_ok) { + return false; + } + + if (napi_get_value_int32(env, prop_value, result) != napi_ok) { + return false; + } + + return true; +} + +// Helper function to validate and parse object properties +bool ParseObjectProperties(napi_env env, + napi_value object, + PropertySpec props[], + size_t prop_count) { + // Process all properties + for (size_t i = 0; i < prop_count; i++) { + if (!GetIntProperty(env, object, props[i].name, props[i].ptr, + props[i].default_val)) { + char error_msg[50]; + snprintf(error_msg, sizeof(error_msg), "%s must be a number", + props[i].name); + napi_throw_error(env, NULL, error_msg); + return false; + } + } + + // Check for unknown properties + napi_value prop_names; + uint32_t count; + napi_get_property_names(env, object, &prop_names); + napi_get_array_length(env, prop_names, &count); + + for (uint32_t i = 0; i < count; i++) { + napi_value prop_name; + napi_get_element(env, prop_names, i, &prop_name); + size_t len; + char name[20]; + napi_get_value_string_utf8(env, prop_name, name, sizeof(name), &len); + + bool found = false; + for (size_t j = 0; j < prop_count; j++) { + if (strcmp(name, props[j].name) == 0) { + found = true; + break; + } + } + if (!found) { + napi_throw_error(env, NULL, "Object contains unknown properties"); + return false; + } + } + + return true; +} + +// virtualDisplay.create() +napi_value create(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + + if (napi_get_cb_info(env, info, &argc, args, NULL, NULL) != napi_ok) { + return NULL; + } + + int width = 1920, height = 1080, x = 0, y = 0; + + PropertySpec props[] = {{"width", 1920, &width}, + {"height", 1080, &height}, + {"x", 0, &x}, + {"y", 0, &y}}; + + if (argc >= 1) { + napi_valuetype valuetype; + if (napi_typeof(env, args[0], &valuetype) != napi_ok) { + napi_throw_error(env, NULL, "Failed to get argument type"); + return NULL; + } + + if (valuetype == napi_object) { + if (!ParseObjectProperties(env, args[0], props, + sizeof(props) / sizeof(props[0]))) { + return NULL; + } + } else { + napi_throw_error(env, NULL, "Expected an object as the argument"); + return NULL; + } + } + + NSInteger displayId = [VirtualDisplayBridge create:width + height:height + x:x + y:y]; + + if (displayId == 0) { + napi_throw_error(env, NULL, "Failed to create virtual display"); + return NULL; + } + + napi_value result; + if (napi_create_int64(env, displayId, &result) != napi_ok) { + return NULL; + } + + return result; +} + +// virtualDisplay.forceCleanup() +napi_value forceCleanup(napi_env env, napi_callback_info info) { + BOOL result = [VirtualDisplayBridge forceCleanup]; + + napi_value js_result; + if (napi_get_boolean(env, result, &js_result) != napi_ok) { + return NULL; + } + + return js_result; +} + +// virtualDisplay.destroy() +napi_value destroy(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + + if (napi_get_cb_info(env, info, &argc, args, NULL, NULL) != napi_ok) { + return NULL; + } + + if (argc < 1) { + napi_throw_error(env, NULL, "Expected number argument"); + return NULL; + } + + int64_t displayId; + if (napi_get_value_int64(env, args[0], &displayId) != napi_ok) { + napi_throw_error(env, NULL, "Expected number argument"); + return NULL; + } + + BOOL result = [VirtualDisplayBridge destroy:(NSInteger)displayId]; + + napi_value js_result; + if (napi_get_boolean(env, result, &js_result) != napi_ok) { + return NULL; + } + + return js_result; +} + +napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor descriptors[] = { + {"create", NULL, create, NULL, NULL, NULL, napi_default, NULL}, + {"destroy", NULL, destroy, NULL, NULL, NULL, napi_default, NULL}, + {"forceCleanup", NULL, forceCleanup, NULL, NULL, NULL, napi_default, + NULL}}; + + if (napi_define_properties(env, exports, + sizeof(descriptors) / sizeof(*descriptors), + descriptors) != napi_ok) { + return NULL; + } + + return exports; +} + +} // namespace + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) \ No newline at end of file diff --git a/spec/package.json b/spec/package.json index a24d3eb770..337dfb4e8b 100644 --- a/spec/package.json +++ b/spec/package.json @@ -13,6 +13,7 @@ "@electron-ci/is-valid-window": "*", "@electron-ci/osr-gpu": "*", "@electron-ci/uv-dlopen": "*", + "@electron-ci/virtual-display": "*", "@electron/fuses": "^2.1.2", "@electron/packager": "^18.3.2", "@types/basic-auth": "^1.1.8", diff --git a/yarn.lock b/yarn.lock index d5537377d3..5cb4853355 100644 --- a/yarn.lock +++ b/yarn.lock @@ -467,6 +467,14 @@ __metadata: languageName: unknown linkType: soft +"@electron-ci/virtual-display@npm:*, @electron-ci/virtual-display@workspace:spec/fixtures/native-addon/virtual-display": + version: 0.0.0-use.local + resolution: "@electron-ci/virtual-display@workspace:spec/fixtures/native-addon/virtual-display" + dependencies: + node-addon-api: "npm:^8.3.0" + languageName: unknown + linkType: soft + "@electron/asar@npm:^3.2.13": version: 3.2.13 resolution: "@electron/asar@npm:3.2.13" @@ -3965,6 +3973,7 @@ __metadata: "@electron-ci/is-valid-window": "npm:*" "@electron-ci/osr-gpu": "npm:*" "@electron-ci/uv-dlopen": "npm:*" + "@electron-ci/virtual-display": "npm:*" "@electron/fuses": "npm:^2.1.2" "@electron/packager": "npm:^18.3.2" "@types/basic-auth": "npm:^1.1.8" @@ -7740,6 +7749,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^8.3.0": + version: 8.7.0 + resolution: "node-addon-api@npm:8.7.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/31a03b00f6b0753ab08360952fdf80a1abb619dcf8125fa1ab07e3a414da050963440c3a86c77a0334c0be7a71acb5e242dc468b79201ee6151c7b943afe946d + languageName: node + linkType: hard + "node-fetch@npm:^2.6.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0"