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 <charles@charleskerr.com>

* 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 <dsanders11@ucsbalum.com>

* docs: update docs/api/structures/base-window-options.md

Co-authored-by: Erick Zhao <erick@hotmail.ca>

* 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 <dsanders11@ucsbalum.com>

* fix: add warning when window state persistence enabled without window name

* docs: lowercase capital B for consistency

---------

Co-authored-by: Charles Kerr <charles@charleskerr.com>
Co-authored-by: David Sanders <dsanders11@ucsbalum.com>
Co-authored-by: Erick Zhao <erick@hotmail.ca>
This commit is contained in:
Nilay Arya
2025-07-10 21:36:36 +05:30
committed by Keeley Hammond
parent a839fb94aa
commit 2290cf57c2
19 changed files with 871 additions and 5 deletions

View File

@@ -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) - An identifier for the window that enables features such as state persistence.
* `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. _Experimental_
* `title` string (optional) - Default window title. Default is `"Electron"`. If the HTML tag `<title>` 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.

View File

@@ -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.

View File

@@ -172,6 +172,7 @@ auto_filenames = {
"docs/api/structures/web-source.md",
"docs/api/structures/window-open-handler-response.md",
"docs/api/structures/window-session-end-event.md",
"docs/api/structures/window-state-persistence.md",
]
sandbox_bundle_deps = [

View File

@@ -170,7 +170,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 +261,7 @@ void BaseWindow::OnWindowWillResize(const gfx::Rect& new_bounds,
}
void BaseWindow::OnWindowResize() {
window_->DebouncedSaveWindowState();
Emit("resize");
}
@@ -276,6 +277,7 @@ void BaseWindow::OnWindowWillMove(const gfx::Rect& new_bounds,
}
void BaseWindow::OnWindowMove() {
window_->DebouncedSaveWindowState();
Emit("move");
}

View File

@@ -40,6 +40,7 @@
#include "services/device/public/cpp/geolocation/geolocation_system_permission_manager.h"
#include "services/network/public/cpp/network_switches.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"
@@ -149,12 +150,12 @@ 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());
// Only use a persistent prefs store when cookie encryption is enabled as that
// is the only key that needs it
base::FilePath prefs_path;
CHECK(base::PathService::Get(electron::DIR_SESSION_DATA, &prefs_path));
prefs_path = prefs_path.Append(FILE_PATH_LITERAL("Local State"));

View File

@@ -9,22 +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/native_window_features.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/views/widget/widget.h"
#if !BUILDFLAG(IS_MAC)
@@ -118,6 +126,29 @@ 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)) {
// Other options will be parsed here in the future.
window_state_persistence_enabled_ = true;
} else if (bool flag; options.Get(options::kWindowStatePersistence, &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()) {
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()) {
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;
@@ -245,7 +276,9 @@ void NativeWindow::InitFromOptions(const gin_helper::Dictionary& options) {
SetBackgroundColor(background_color);
SetTitle(options.ValueOrDefault(options::kTitle, Browser::Get()->GetName()));
// TODO(nilayarya): Save window state after restoration logic is implemented
// here.
SaveWindowState();
// Then show it.
if (options.ValueOrDefault(options::kShow, true))
Show();
@@ -796,6 +829,66 @@ 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 (!prefs_ || window_name_.empty())
return;
ScopedDictPrefUpdate update(prefs_, electron::kWindowStates);
const base::Value::Dict* existing_prefs = update->FindDict(window_name_);
gfx::Rect bounds = GetBounds();
// 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::Value::Dict 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());
const display::Screen* screen = display::Screen::GetScreen();
const display::Display display = screen->GetDisplayMatching(bounds);
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() {
if (save_window_state_timer_.IsRunning()) {
save_window_state_timer_.FireNow();
} else {
SaveWindowState();
}
}
// static
bool NativeWindow::PlatformHasClientFrame() {
#if defined(USE_OZONE)

View File

@@ -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;
@@ -430,6 +433,17 @@ 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();
protected:
NativeWindow(int32_t base_window_id,
const gin_helper::Dictionary& options,
@@ -494,6 +508,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_;
@@ -552,6 +570,17 @@ class NativeWindow : public views::WidgetDelegate {
gfx::Rect overlay_rect_;
// 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;
// Timer to debounce window state saving operations.
base::OneShotTimer save_window_state_timer_;
base::WeakPtrFactory<NativeWindow> weak_factory_{this};
};

View File

@@ -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

View File

@@ -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";

View File

@@ -7230,4 +7230,459 @@ describe('BrowserWindow module', () => {
await screenCapture.expectColorAtCenterMatches(HexColors.BLUE);
});
});
describe('draggable regions', () => {
afterEach(closeAllWindows);
ifit(hasCapturableScreen())('should allow the window to be dragged when enabled', async () => {
// FIXME: nut-js has been removed from npm; we need to find a replacement
// WOA fails to load libnut so we're using require to defer loading only
// on supported platforms.
// "@nut-tree\libnut-win32\build\Release\libnut.node is not a valid Win32 application."
// @ts-ignore: nut-js is an optional dependency so it may not be installed
const { mouse, straightTo, centerOf, Region, Button } = require('@nut-tree/nut-js') as typeof import('@nut-tree/nut-js');
const display = screen.getPrimaryDisplay();
const w = new BrowserWindow({
x: 0,
y: 0,
width: display.bounds.width / 2,
height: display.bounds.height / 2,
frame: false,
titleBarStyle: 'hidden'
});
const overlayHTML = path.join(__dirname, 'fixtures', 'pages', 'overlay.html');
w.loadFile(overlayHTML);
await once(w, 'ready-to-show');
const winBounds = w.getBounds();
const titleBarHeight = 30;
const titleBarRegion = new Region(winBounds.x, winBounds.y, winBounds.width, titleBarHeight);
const screenRegion = new Region(display.bounds.x, display.bounds.y, display.bounds.width, display.bounds.height);
const startPos = w.getPosition();
await mouse.setPosition(await centerOf(titleBarRegion));
await mouse.pressButton(Button.LEFT);
await mouse.drag(straightTo(centerOf(screenRegion)));
// Wait for move to complete
await Promise.race([
once(w, 'move'),
setTimeout(100) // fallback for possible race condition
]);
const endPos = w.getPosition();
expect(startPos).to.not.deep.equal(endPos);
});
ifit(hasCapturableScreen())('should allow the window to be dragged when no WCO and --webkit-app-region: drag enabled', async () => {
// FIXME: nut-js has been removed from npm; we need to find a replacement
// @ts-ignore: nut-js is an optional dependency so it may not be installed
const { mouse, straightTo, centerOf, Region, Button } = require('@nut-tree/nut-js') as typeof import('@nut-tree/nut-js');
const display = screen.getPrimaryDisplay();
const w = new BrowserWindow({
x: 0,
y: 0,
width: display.bounds.width / 2,
height: display.bounds.height / 2,
frame: false
});
const basePageHTML = path.join(__dirname, 'fixtures', 'pages', 'base-page.html');
w.loadFile(basePageHTML);
await once(w, 'ready-to-show');
await w.webContents.executeJavaScript(`
const style = document.createElement('style');
style.innerHTML = \`
#titlebar {
background-color: red;
height: 30px;
width: 100%;
-webkit-user-select: none;
-webkit-app-region: drag;
position: fixed;
top: 0;
left: 0;
z-index: 1000000000000;
}
\`;
const titleBar = document.createElement('title-bar');
titleBar.id = 'titlebar';
titleBar.textContent = 'test-titlebar';
document.body.append(style);
document.body.append(titleBar);
`);
// allow time for titlebar to finish loading
await setTimeout(2000);
const winBounds = w.getBounds();
const titleBarHeight = 30;
const titleBarRegion = new Region(winBounds.x, winBounds.y, winBounds.width, titleBarHeight);
const screenRegion = new Region(display.bounds.x, display.bounds.y, display.bounds.width, display.bounds.height);
const startPos = w.getPosition();
await mouse.setPosition(await centerOf(titleBarRegion));
await mouse.pressButton(Button.LEFT);
await mouse.drag(straightTo(centerOf(screenRegion)));
// Wait for move to complete
await Promise.race([
once(w, 'move'),
setTimeout(1000) // fallback for possible race condition
]);
const endPos = w.getPosition();
expect(startPos).to.not.deep.equal(endPos);
});
});
describe('windowStatePersistence', () => {
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');
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;
};
// 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');
});
ifit(hasCapturableScreen())('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);
});
ifit(hasCapturableScreen())('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);
});
ifit(hasCapturableScreen())('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);
});
ifit(hasCapturableScreen())('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);
});
ifit(hasCapturableScreen())('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);
});
ifit(hasCapturableScreen())('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);
});
ifit(hasCapturableScreen())('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', () => {
ifit(hasCapturableScreen())('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);
});
ifit(hasCapturableScreen())('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');
// Helper to get preferences file modification time
const getPrefsModTime = (): Date => {
try {
return fs.statSync(preferencesPath).mtime;
} catch {
throw new Error(`Test requires preferences file to exist at path: ${preferencesPath}.`);
}
};
// Helper to wait for file modification with 20 second default timeout
const waitForPrefsUpdate = async (initialModTime: Date, timeoutMs: number = 20000): Promise<void> => {
const startTime = Date.now();
while (true) {
const currentModTime = getPrefsModTime();
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);
}
};
beforeEach(async () => {
await setTimeout(2000);
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();
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();
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();
const resized = once(w, 'resize');
w.setSize(500, 400);
await resized;
await waitForPrefsUpdate(initialModTime);
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();
const resize1 = once(w, 'resize');
w.setSize(500, 400);
await resize1;
// Wait for any potential save to occur
await setTimeout(1000);
const afterFirstResize = getPrefsModTime();
const resize2 = once(w, 'resize');
w.setSize(600, 500);
await resize2;
// Wait for any potential save to occur
await setTimeout(1000);
const afterSecondResize = getPrefsModTime();
const resize3 = once(w, 'resize');
w.setSize(700, 600);
await resize3;
// Wait for any potential save to occur
await setTimeout(1000);
const afterThirdResize = getPrefsModTime();
await waitForPrefsUpdate(initialModTime);
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);
});
it('should not save window bounds when main thread is busy', async () => {
// Wait for preferences file to be created if its the first time we're running the test
await waitForPrefsFileCreation(preferencesPath);
const initialModTime = getPrefsModTime();
const moved = once(w, 'move');
w.setPosition(100, 100);
await moved;
const startTime = Date.now();
// Keep main thread busy for 25 seconds
while (Date.now() - startTime < 25000);
const finalModTime = getPrefsModTime();
expect(finalModTime.getTime()).to.equal(initialModTime.getTime());
});
});
});
});
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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: false
});
w.close();
setTimeout(() => {
app.quit();
}, 1000);
});

View File

@@ -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);
});