@workbench-kit/electron-shell 0.0.2-prototype.0.2.26 → 0.0.2-prototype.0.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # `@workbench-kit/electron-shell`
2
2
 
3
- Electron **main-process** helpers (window controls, asset protocol, secret vault)
4
- and a typed **preload** scaffold. The package stays Electron-free — hosts inject
5
- narrow `ipcMain` / `ipcRenderer` / `contextBridge` surfaces.
3
+ Electron **main-process** helpers (application quit lifecycle, window controls,
4
+ asset protocol, secret vault) and a typed **preload** scaffold. The package stays
5
+ Electron-free hosts inject narrow Electron surfaces.
6
6
 
7
7
  Published on npm with the **`prototype`** dist tag.
8
8
 
@@ -16,12 +16,81 @@ pnpm add @workbench-kit/electron-shell@prototype
16
16
 
17
17
  ```ts
18
18
  import {
19
+ createApplicationQuitGuard,
19
20
  registerWindowControlIpc,
20
21
  createEncryptedSecretVault,
21
22
  requireOwnedWindowForSender,
22
23
  } from '@workbench-kit/electron-shell';
23
24
  ```
24
25
 
26
+ ## Focused entries
27
+
28
+ Performance-sensitive hosts can import only the reusable Electron boundary they
29
+ need. Product channel names, URL catalogs, storage paths, and policy remain
30
+ host-owned.
31
+
32
+ ```ts
33
+ import { openAllowlistedExternalLink } from '@workbench-kit/electron-shell/external-links';
34
+ import { createApplicationQuitGuard } from '@workbench-kit/electron-shell/application-quit-guard';
35
+ import { requireOwnedWindowForSender } from '@workbench-kit/electron-shell/sender-security';
36
+ import {
37
+ createWindowControlsBridge,
38
+ registerWindowControlIpc,
39
+ } from '@workbench-kit/electron-shell/window-controls';
40
+ ```
41
+
42
+ `registerWindowControlIpc` validates the sender through the host-injected window
43
+ resolver. `createWindowControlsBridge().toggleMaximized()` resolves to the final
44
+ maximized state returned by the main handler.
45
+
46
+ ## Application quit guard (`./application-quit-guard`)
47
+
48
+ Electron's `before-quit` event must be vetoed synchronously, even when checking
49
+ dirty state or asking for a decision is asynchronous. The guard coordinates that
50
+ flow while the host retains ownership of application state, prompts, save/discard
51
+ operations, and event registration.
52
+
53
+ ```ts
54
+ const quitGuard = createApplicationQuitGuard({
55
+ isDirty: (signal) => documentStore.isDirty({ signal }),
56
+ requestDecision: (signal) => quitPrompt.requestDecision({ signal }),
57
+ save: (signal) => documentStore.saveAll({ signal }),
58
+ discard: (signal) => documentStore.discardAll({ signal }),
59
+ resumeQuit: () => electronApp.quit(),
60
+ timeoutMs: 30_000,
61
+ });
62
+
63
+ electronApp.on('before-quit', (event) => {
64
+ void quitGuard.handleBeforeQuit(event);
65
+ });
66
+ ```
67
+
68
+ Repeated quit events are coalesced into the active request. After save or discard,
69
+ the guard rechecks dirty state and resumes only when clean. Errors, timeout, and a
70
+ still-dirty recheck fail closed. `cancelPending()` aborts an obsolete request and
71
+ invalidates any late guard completion; call it when the owning lifecycle is
72
+ replaced. A callback that has already started must honor its `AbortSignal` to stop
73
+ its own side effects.
74
+ The injected `resumeQuit` port must re-enter the registered guard synchronously;
75
+ an asynchronous wrapper is treated as a fresh request. OS shutdown paths that do
76
+ not emit `before-quit`, and updater flows that emit it after windows close, remain
77
+ host-owned lifecycle concerns outside this guard.
78
+
79
+ For external links, keep the product allowlist outside the Kit and pass only an
80
+ opaque link id into the generic helper:
81
+
82
+ ```ts
83
+ const PRODUCT_LINKS = {
84
+ docs: 'https://example.com/docs',
85
+ } as const;
86
+
87
+ await openAllowlistedExternalLink({
88
+ allowlist: PRODUCT_LINKS,
89
+ linkId: 'docs',
90
+ openExternal: (url) => electronShell.openExternal(url),
91
+ });
92
+ ```
93
+
25
94
  ## Typed preload scaffold (`./preload`)
26
95
 
27
96
  Secure renderer↔main pattern: allowlisted invoke/subscribe + `contextBridge`
@@ -74,12 +143,13 @@ declare global {
74
143
  }
75
144
 
76
145
  await window.workbenchKit.window.minimize();
146
+ const isMaximized = await window.workbenchKit.window.toggleMaximized();
77
147
  ```
78
148
 
79
149
  ### Enablement checklist
80
150
 
81
151
  1. `contextIsolation: true`, `nodeIntegration: false` on BrowserWindow
82
- 2. Register main IPC with `registerWindowControlIpc` + `requireOwnedWindowForSender`
152
+ 2. Register main IPC with `registerWindowControlIpc` + an owned-window resolver
83
153
  3. Build preload with `createWorkbenchKitPreloadApi` + `exposeWorkbenchKitPreload`
84
154
  4. Do **not** expose `ipcRenderer`, `require`, or Node builtins to the page
85
155
  5. Keep channel names host-owned; only allowlist channels the scaffold will call
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createApplicationQuitGuard = createApplicationQuitGuard;
4
+ class ApplicationQuitGuardAbort extends Error {
5
+ constructor(kind) {
6
+ super(`Application quit guard request was ${kind}.`);
7
+ this.kind = kind;
8
+ this.name = 'ApplicationQuitGuardAbort';
9
+ }
10
+ }
11
+ function validateTimeoutMs(timeoutMs) {
12
+ if (timeoutMs !== undefined &&
13
+ (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647)) {
14
+ throw new RangeError('Application quit guard timeoutMs must be a positive safe integer no greater than 2147483647.');
15
+ }
16
+ }
17
+ function raceWithAbort(operation, signal, readAbortKind) {
18
+ if (signal.aborted) {
19
+ return Promise.reject(new ApplicationQuitGuardAbort(readAbortKind()));
20
+ }
21
+ const operationPromise = Promise.resolve().then(() => {
22
+ if (signal.aborted) {
23
+ throw new ApplicationQuitGuardAbort(readAbortKind());
24
+ }
25
+ return operation();
26
+ });
27
+ return new Promise((resolve, reject) => {
28
+ const onAbort = () => {
29
+ reject(new ApplicationQuitGuardAbort(readAbortKind()));
30
+ };
31
+ signal.addEventListener('abort', onAbort, { once: true });
32
+ operationPromise.then((value) => {
33
+ signal.removeEventListener('abort', onAbort);
34
+ resolve(value);
35
+ }, (error) => {
36
+ signal.removeEventListener('abort', onAbort);
37
+ reject(error);
38
+ });
39
+ });
40
+ }
41
+ /**
42
+ * Coordinate an Electron-style before-quit boundary without importing Electron.
43
+ * The host owns event registration, dirty state, prompts, persistence, and quit invocation.
44
+ */
45
+ function createApplicationQuitGuard(options) {
46
+ const timeoutMs = options.timeoutMs;
47
+ validateTimeoutMs(timeoutMs);
48
+ let generation = 0;
49
+ let inFlight = null;
50
+ let pendingController = null;
51
+ let resumePermit = false;
52
+ const startRequest = () => {
53
+ const requestGeneration = ++generation;
54
+ const controller = new AbortController();
55
+ let abortKind = 'cancelled';
56
+ let timeout;
57
+ pendingController = controller;
58
+ if (timeoutMs !== undefined) {
59
+ timeout = setTimeout(() => {
60
+ if (requestGeneration !== generation || controller.signal.aborted) {
61
+ return;
62
+ }
63
+ abortKind = 'timeout';
64
+ controller.abort();
65
+ }, timeoutMs);
66
+ }
67
+ const isCurrent = () => requestGeneration === generation;
68
+ const run = (operation) => raceWithAbort(operation, controller.signal, () => abortKind);
69
+ const recheckAndResume = async (reason) => {
70
+ const dirty = await run(() => options.isDirty(controller.signal));
71
+ if (dirty !== false && dirty !== true) {
72
+ throw new TypeError('Application quit guard isDirty must return a boolean.');
73
+ }
74
+ if (dirty) {
75
+ return { status: 'blocked', reason: 'still-dirty' };
76
+ }
77
+ return resume(reason);
78
+ };
79
+ const resume = (reason) => {
80
+ // The decision is now irreversible. `cancelPending` must not report a cancellable request
81
+ // while the host is synchronously re-entering before-quit.
82
+ if (isCurrent()) {
83
+ pendingController = null;
84
+ }
85
+ resumePermit = true;
86
+ try {
87
+ options.resumeQuit();
88
+ }
89
+ finally {
90
+ // Electron re-enters before-quit synchronously. Never leave a permit for a later request.
91
+ resumePermit = false;
92
+ }
93
+ return { status: 'proceed', reason };
94
+ };
95
+ const execute = async () => {
96
+ try {
97
+ const dirty = await run(() => options.isDirty(controller.signal));
98
+ if (dirty !== false && dirty !== true) {
99
+ throw new TypeError('Application quit guard isDirty must return a boolean.');
100
+ }
101
+ if (!dirty) {
102
+ return resume('clean');
103
+ }
104
+ const decision = await run(() => options.requestDecision(controller.signal));
105
+ if (decision === 'cancel') {
106
+ return { status: 'cancelled' };
107
+ }
108
+ if (decision === 'save') {
109
+ await run(() => options.save(controller.signal));
110
+ return await recheckAndResume('saved');
111
+ }
112
+ if (decision === 'discard') {
113
+ await run(() => options.discard(controller.signal));
114
+ return await recheckAndResume('discarded');
115
+ }
116
+ throw new TypeError('Application quit guard requestDecision must return save, discard, or cancel.');
117
+ }
118
+ catch (error) {
119
+ if (error instanceof ApplicationQuitGuardAbort) {
120
+ return error.kind === 'timeout'
121
+ ? { status: 'blocked', reason: 'timeout' }
122
+ : { status: 'cancelled' };
123
+ }
124
+ return { status: 'blocked', reason: 'error', error };
125
+ }
126
+ finally {
127
+ if (timeout !== undefined) {
128
+ clearTimeout(timeout);
129
+ }
130
+ if (isCurrent()) {
131
+ inFlight = null;
132
+ pendingController = null;
133
+ }
134
+ }
135
+ };
136
+ return execute();
137
+ };
138
+ return {
139
+ handleBeforeQuit(event) {
140
+ if (resumePermit) {
141
+ resumePermit = false;
142
+ return undefined;
143
+ }
144
+ event.preventDefault();
145
+ if (inFlight !== null) {
146
+ return inFlight;
147
+ }
148
+ inFlight = startRequest();
149
+ return inFlight;
150
+ },
151
+ cancelPending() {
152
+ if (inFlight === null || pendingController === null) {
153
+ return false;
154
+ }
155
+ const controller = pendingController;
156
+ generation += 1;
157
+ inFlight = null;
158
+ pendingController = null;
159
+ controller.abort();
160
+ return true;
161
+ },
162
+ };
163
+ }
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InvalidExternalLinkUrlError = exports.UnknownExternalLinkIdError = void 0;
4
+ exports.openAllowlistedExternalLink = openAllowlistedExternalLink;
5
+ class UnknownExternalLinkIdError extends Error {
6
+ constructor(linkId) {
7
+ super('External link id is not in the allowlist.');
8
+ this.code = 'unknown_external_link_id';
9
+ this.name = 'UnknownExternalLinkIdError';
10
+ this.linkId = linkId;
11
+ }
12
+ }
13
+ exports.UnknownExternalLinkIdError = UnknownExternalLinkIdError;
14
+ class InvalidExternalLinkUrlError extends Error {
15
+ constructor(linkId, message = 'Allowlisted external link URL is invalid.') {
16
+ super(message);
17
+ this.code = 'invalid_external_link_url';
18
+ this.name = 'InvalidExternalLinkUrlError';
19
+ this.linkId = linkId;
20
+ }
21
+ }
22
+ exports.InvalidExternalLinkUrlError = InvalidExternalLinkUrlError;
23
+ function assertHttpsUrl(linkId, url) {
24
+ let parsed;
25
+ try {
26
+ parsed = new URL(url);
27
+ }
28
+ catch {
29
+ throw new InvalidExternalLinkUrlError(linkId, 'Allowlisted external link URL is not absolute.');
30
+ }
31
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
32
+ throw new InvalidExternalLinkUrlError(linkId, 'Allowlisted external link URL must use http: or https:.');
33
+ }
34
+ }
35
+ /**
36
+ * Resolve an opaque link id through a host-injected allowlist, then open via `openExternal`.
37
+ * Pair IPC entry with `requireOwnedWindowForSender`. No product URL catalogs in kit.
38
+ */
39
+ async function openAllowlistedExternalLink(input) {
40
+ const linkId = input.linkId.trim();
41
+ if (linkId.length === 0) {
42
+ throw new UnknownExternalLinkIdError(linkId);
43
+ }
44
+ const url = input.allowlist[linkId];
45
+ if (typeof url !== 'string' || url.trim().length === 0) {
46
+ throw new UnknownExternalLinkIdError(linkId);
47
+ }
48
+ const resolvedUrl = url.trim();
49
+ assertHttpsUrl(linkId, resolvedUrl);
50
+ await input.openExternal(resolvedUrl);
51
+ }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UntrustedIpcSenderError = void 0;
4
+ exports.requireOwnedWindowForSender = requireOwnedWindowForSender;
5
+ class UntrustedIpcSenderError extends Error {
6
+ constructor(message = 'IPC sender is not bound to an owned window.') {
7
+ super(message);
8
+ this.code = 'untrusted_ipc_sender';
9
+ this.name = 'UntrustedIpcSenderError';
10
+ }
11
+ }
12
+ exports.UntrustedIpcSenderError = UntrustedIpcSenderError;
13
+ /**
14
+ * Resolve an IPC sender to a host-owned window handle, or throw.
15
+ * Hosts own registry membership; kit owns the gate.
16
+ */
17
+ function requireOwnedWindowForSender(sender, resolveOwnedWindow) {
18
+ const windowHandle = resolveOwnedWindow(sender);
19
+ if (windowHandle === null) {
20
+ throw new UntrustedIpcSenderError();
21
+ }
22
+ return windowHandle;
23
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nextMaximizedState = nextMaximizedState;
4
+ exports.registerWindowControlIpc = registerWindowControlIpc;
5
+ exports.createWindowControlsBridge = createWindowControlsBridge;
6
+ const require_owned_window_for_sender_js_1 = require("../security/require-owned-window-for-sender.js");
7
+ /** Pure helper: next maximized state after a toggle. */
8
+ function nextMaximizedState(isMaximized) {
9
+ return !isMaximized;
10
+ }
11
+ /**
12
+ * Register frameless window-control IPC handlers on an injected ipcMain.
13
+ * Hosts inject channel names and resolve owned windows (pair with sender gate).
14
+ */
15
+ function registerWindowControlIpc(options) {
16
+ const { ipcMain, channels, resolveWindow, resolveWebContents } = options;
17
+ const unsubscribers = new Map();
18
+ const resolveOwned = (sender) => (0, require_owned_window_for_sender_js_1.requireOwnedWindowForSender)(sender, resolveWindow);
19
+ const ensureMaximizedPush = (sender, windowSurface) => {
20
+ if (!windowSurface.onMaximizedChange || unsubscribers.has(sender)) {
21
+ return;
22
+ }
23
+ const unsubscribe = windowSurface.onMaximizedChange((maximized) => {
24
+ const webContents = resolveWebContents?.(sender) ?? null;
25
+ webContents?.send(channels.maximizedChanged, maximized);
26
+ });
27
+ unsubscribers.set(sender, unsubscribe);
28
+ };
29
+ ipcMain.handle(channels.minimize, (event) => {
30
+ const windowSurface = resolveOwned(event.sender);
31
+ ensureMaximizedPush(event.sender, windowSurface);
32
+ windowSurface.minimize();
33
+ });
34
+ ipcMain.handle(channels.toggleMaximized, (event) => {
35
+ const windowSurface = resolveOwned(event.sender);
36
+ ensureMaximizedPush(event.sender, windowSurface);
37
+ if (windowSurface.isMaximized()) {
38
+ windowSurface.unmaximize();
39
+ }
40
+ else {
41
+ windowSurface.maximize();
42
+ }
43
+ return windowSurface.isMaximized();
44
+ });
45
+ ipcMain.handle(channels.close, (event) => {
46
+ const windowSurface = resolveOwned(event.sender);
47
+ windowSurface.close();
48
+ });
49
+ ipcMain.handle(channels.isMaximized, (event) => {
50
+ const windowSurface = resolveOwned(event.sender);
51
+ ensureMaximizedPush(event.sender, windowSurface);
52
+ return windowSurface.isMaximized();
53
+ });
54
+ return () => {
55
+ for (const unsubscribe of unsubscribers.values()) {
56
+ unsubscribe();
57
+ }
58
+ unsubscribers.clear();
59
+ };
60
+ }
61
+ /**
62
+ * Preload/renderer bridge factory for window controls.
63
+ * Channel names are injected; kit owns the invoke/subscribe shape.
64
+ */
65
+ function createWindowControlsBridge(options) {
66
+ const { channels, invoke, subscribe } = options;
67
+ return {
68
+ minimize: async () => {
69
+ await invoke(channels.minimize);
70
+ },
71
+ toggleMaximized: async () => {
72
+ const value = await invoke(channels.toggleMaximized);
73
+ return Boolean(value);
74
+ },
75
+ close: async () => {
76
+ await invoke(channels.close);
77
+ },
78
+ isMaximized: async () => {
79
+ const value = await invoke(channels.isMaximized);
80
+ return Boolean(value);
81
+ },
82
+ onMaximizedChanged: (listener) => subscribe(channels.maximizedChanged, (maximized) => {
83
+ listener(Boolean(maximized));
84
+ }),
85
+ };
86
+ }
package/package.json CHANGED
@@ -1,20 +1,65 @@
1
1
  {
2
2
  "name": "@workbench-kit/electron-shell",
3
- "version": "0.0.2-prototype.0.2.26",
3
+ "version": "0.0.2-prototype.0.2.27",
4
4
  "private": false,
5
5
  "type": "module",
6
+ "types": "./src/index.ts",
7
+ "typesVersions": {
8
+ "*": {
9
+ "application-quit-guard": [
10
+ "src/lifecycle/application-quit-guard.ts"
11
+ ],
12
+ "external-links": [
13
+ "src/security/open-allowlisted-external-link.ts"
14
+ ],
15
+ "preload": [
16
+ "src/preload/index.ts"
17
+ ],
18
+ "sender-security": [
19
+ "src/security/require-owned-window-for-sender.ts"
20
+ ],
21
+ "window-controls": [
22
+ "src/window/window-controls.ts"
23
+ ]
24
+ }
25
+ },
6
26
  "exports": {
7
27
  ".": "./src/index.ts",
8
- "./preload": "./src/preload/index.ts"
28
+ "./application-quit-guard": {
29
+ "types": "./src/lifecycle/application-quit-guard.ts",
30
+ "require": "./dist/lifecycle/application-quit-guard.js",
31
+ "import": "./src/lifecycle/application-quit-guard.ts",
32
+ "default": "./src/lifecycle/application-quit-guard.ts"
33
+ },
34
+ "./external-links": {
35
+ "types": "./src/security/open-allowlisted-external-link.ts",
36
+ "require": "./dist/security/open-allowlisted-external-link.js",
37
+ "import": "./src/security/open-allowlisted-external-link.ts",
38
+ "default": "./src/security/open-allowlisted-external-link.ts"
39
+ },
40
+ "./preload": "./src/preload/index.ts",
41
+ "./sender-security": {
42
+ "types": "./src/security/require-owned-window-for-sender.ts",
43
+ "require": "./dist/security/require-owned-window-for-sender.js",
44
+ "import": "./src/security/require-owned-window-for-sender.ts",
45
+ "default": "./src/security/require-owned-window-for-sender.ts"
46
+ },
47
+ "./window-controls": {
48
+ "types": "./src/window/window-controls.ts",
49
+ "require": "./dist/window/window-controls.js",
50
+ "import": "./src/window/window-controls.ts",
51
+ "default": "./src/window/window-controls.ts"
52
+ }
9
53
  },
10
54
  "files": [
55
+ "dist",
11
56
  "src",
12
57
  "!src/**/*.test.ts",
13
58
  "!src/**/*.test.tsx",
14
59
  "!src/**/*.stories.ts",
15
60
  "!src/**/*.stories.tsx"
16
61
  ],
17
- "description": "Electron main-process helpers for Workbench Kit hosts (window controls, asset protocol, secret vault) plus a typed preload scaffold.",
62
+ "description": "Electron main-process helpers for Workbench Kit hosts (quit lifecycle, window controls, asset protocol, secret vault) plus a typed preload scaffold.",
18
63
  "publishConfig": {
19
64
  "access": "public",
20
65
  "tag": "prototype",
@@ -26,6 +71,8 @@
26
71
  "directory": "packages/electron-shell"
27
72
  },
28
73
  "scripts": {
74
+ "prebuild": "node scripts/clean-dist.mjs",
75
+ "build": "tsc -p tsconfig.cjs.json && node scripts/write-cjs-package.mjs",
29
76
  "test": "vitest run src",
30
77
  "typecheck": "tsc -p tsconfig.json --noEmit"
31
78
  }
package/src/index.ts CHANGED
@@ -1,3 +1,12 @@
1
+ export {
2
+ createApplicationQuitGuard,
3
+ type ApplicationQuitDecision,
4
+ type ApplicationQuitEvent,
5
+ type ApplicationQuitGuard,
6
+ type ApplicationQuitGuardResult,
7
+ type ApplicationQuitProceedReason,
8
+ type CreateApplicationQuitGuardOptions,
9
+ } from './lifecycle/application-quit-guard.js';
1
10
  export {
2
11
  createEncryptedSecretVault,
3
12
  EncryptionUnavailableError,
@@ -0,0 +1,251 @@
1
+ export interface ApplicationQuitEvent {
2
+ preventDefault(): void;
3
+ }
4
+
5
+ export type ApplicationQuitDecision = 'cancel' | 'discard' | 'save';
6
+
7
+ export type ApplicationQuitProceedReason = 'clean' | 'discarded' | 'saved';
8
+
9
+ export type ApplicationQuitGuardResult =
10
+ | {
11
+ readonly status: 'proceed';
12
+ readonly reason: ApplicationQuitProceedReason;
13
+ }
14
+ | {
15
+ readonly status: 'cancelled';
16
+ }
17
+ | {
18
+ readonly status: 'blocked';
19
+ readonly reason: 'still-dirty' | 'timeout';
20
+ }
21
+ | {
22
+ readonly status: 'blocked';
23
+ readonly reason: 'error';
24
+ readonly error: unknown;
25
+ };
26
+
27
+ export interface CreateApplicationQuitGuardOptions {
28
+ /** Return whether application-owned state currently needs a quit decision. */
29
+ readonly isDirty: (signal: AbortSignal) => boolean | Promise<boolean>;
30
+ /** Ask the integrating host how a dirty quit request should be handled. */
31
+ readonly requestDecision: (
32
+ signal: AbortSignal,
33
+ ) => ApplicationQuitDecision | Promise<ApplicationQuitDecision>;
34
+ /** Persist dirty state before the guard checks it again. */
35
+ readonly save: (signal: AbortSignal) => void | Promise<void>;
36
+ /** Drop dirty state before the guard checks it again. */
37
+ readonly discard: (signal: AbortSignal) => void | Promise<void>;
38
+ /** Re-enter the host quit path. The next synchronous before-quit event is allowed once. */
39
+ readonly resumeQuit: () => void;
40
+ /** Optional bound for the complete check, decision, action, and recheck flow. */
41
+ readonly timeoutMs?: number;
42
+ }
43
+
44
+ export interface ApplicationQuitGuard {
45
+ /**
46
+ * Veto and coordinate a before-quit request. Concurrent requests share one result.
47
+ * Returns undefined only for the one synchronous re-entry allowed by `resumeQuit`.
48
+ */
49
+ handleBeforeQuit(event: ApplicationQuitEvent): Promise<ApplicationQuitGuardResult> | undefined;
50
+ /**
51
+ * Abort and invalidate the current request. Late guard completion is ignored, but an
52
+ * already-started callback must honor its AbortSignal to stop its own side effects.
53
+ * Returns false when no request is pending.
54
+ */
55
+ cancelPending(): boolean;
56
+ }
57
+
58
+ type AbortKind = 'cancelled' | 'timeout';
59
+
60
+ class ApplicationQuitGuardAbort extends Error {
61
+ constructor(readonly kind: AbortKind) {
62
+ super(`Application quit guard request was ${kind}.`);
63
+ this.name = 'ApplicationQuitGuardAbort';
64
+ }
65
+ }
66
+
67
+ function validateTimeoutMs(timeoutMs: number | undefined): void {
68
+ if (
69
+ timeoutMs !== undefined &&
70
+ (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
71
+ ) {
72
+ throw new RangeError(
73
+ 'Application quit guard timeoutMs must be a positive safe integer no greater than 2147483647.',
74
+ );
75
+ }
76
+ }
77
+
78
+ function raceWithAbort<Value>(
79
+ operation: () => Value | Promise<Value>,
80
+ signal: AbortSignal,
81
+ readAbortKind: () => AbortKind,
82
+ ): Promise<Value> {
83
+ if (signal.aborted) {
84
+ return Promise.reject(new ApplicationQuitGuardAbort(readAbortKind()));
85
+ }
86
+
87
+ const operationPromise = Promise.resolve().then(() => {
88
+ if (signal.aborted) {
89
+ throw new ApplicationQuitGuardAbort(readAbortKind());
90
+ }
91
+ return operation();
92
+ });
93
+ return new Promise<Value>((resolve, reject) => {
94
+ const onAbort = (): void => {
95
+ reject(new ApplicationQuitGuardAbort(readAbortKind()));
96
+ };
97
+
98
+ signal.addEventListener('abort', onAbort, { once: true });
99
+ operationPromise.then(
100
+ (value) => {
101
+ signal.removeEventListener('abort', onAbort);
102
+ resolve(value);
103
+ },
104
+ (error: unknown) => {
105
+ signal.removeEventListener('abort', onAbort);
106
+ reject(error);
107
+ },
108
+ );
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Coordinate an Electron-style before-quit boundary without importing Electron.
114
+ * The host owns event registration, dirty state, prompts, persistence, and quit invocation.
115
+ */
116
+ export function createApplicationQuitGuard(
117
+ options: CreateApplicationQuitGuardOptions,
118
+ ): ApplicationQuitGuard {
119
+ const timeoutMs = options.timeoutMs;
120
+ validateTimeoutMs(timeoutMs);
121
+
122
+ let generation = 0;
123
+ let inFlight: Promise<ApplicationQuitGuardResult> | null = null;
124
+ let pendingController: AbortController | null = null;
125
+ let resumePermit = false;
126
+
127
+ const startRequest = (): Promise<ApplicationQuitGuardResult> => {
128
+ const requestGeneration = ++generation;
129
+ const controller = new AbortController();
130
+ let abortKind: AbortKind = 'cancelled';
131
+ let timeout: ReturnType<typeof setTimeout> | undefined;
132
+
133
+ pendingController = controller;
134
+ if (timeoutMs !== undefined) {
135
+ timeout = setTimeout(() => {
136
+ if (requestGeneration !== generation || controller.signal.aborted) {
137
+ return;
138
+ }
139
+ abortKind = 'timeout';
140
+ controller.abort();
141
+ }, timeoutMs);
142
+ }
143
+
144
+ const isCurrent = (): boolean => requestGeneration === generation;
145
+ const run = <Value>(operation: () => Value | Promise<Value>): Promise<Value> =>
146
+ raceWithAbort(operation, controller.signal, () => abortKind);
147
+
148
+ const recheckAndResume = async (
149
+ reason: Exclude<ApplicationQuitProceedReason, 'clean'>,
150
+ ): Promise<ApplicationQuitGuardResult> => {
151
+ const dirty = await run(() => options.isDirty(controller.signal));
152
+ if (dirty !== false && dirty !== true) {
153
+ throw new TypeError('Application quit guard isDirty must return a boolean.');
154
+ }
155
+ if (dirty) {
156
+ return { status: 'blocked', reason: 'still-dirty' };
157
+ }
158
+ return resume(reason);
159
+ };
160
+
161
+ const resume = (reason: ApplicationQuitProceedReason): ApplicationQuitGuardResult => {
162
+ // The decision is now irreversible. `cancelPending` must not report a cancellable request
163
+ // while the host is synchronously re-entering before-quit.
164
+ if (isCurrent()) {
165
+ pendingController = null;
166
+ }
167
+ resumePermit = true;
168
+ try {
169
+ options.resumeQuit();
170
+ } finally {
171
+ // Electron re-enters before-quit synchronously. Never leave a permit for a later request.
172
+ resumePermit = false;
173
+ }
174
+ return { status: 'proceed', reason };
175
+ };
176
+
177
+ const execute = async (): Promise<ApplicationQuitGuardResult> => {
178
+ try {
179
+ const dirty = await run(() => options.isDirty(controller.signal));
180
+ if (dirty !== false && dirty !== true) {
181
+ throw new TypeError('Application quit guard isDirty must return a boolean.');
182
+ }
183
+ if (!dirty) {
184
+ return resume('clean');
185
+ }
186
+
187
+ const decision = await run(() => options.requestDecision(controller.signal));
188
+ if (decision === 'cancel') {
189
+ return { status: 'cancelled' };
190
+ }
191
+ if (decision === 'save') {
192
+ await run(() => options.save(controller.signal));
193
+ return await recheckAndResume('saved');
194
+ }
195
+ if (decision === 'discard') {
196
+ await run(() => options.discard(controller.signal));
197
+ return await recheckAndResume('discarded');
198
+ }
199
+ throw new TypeError(
200
+ 'Application quit guard requestDecision must return save, discard, or cancel.',
201
+ );
202
+ } catch (error: unknown) {
203
+ if (error instanceof ApplicationQuitGuardAbort) {
204
+ return error.kind === 'timeout'
205
+ ? { status: 'blocked', reason: 'timeout' }
206
+ : { status: 'cancelled' };
207
+ }
208
+ return { status: 'blocked', reason: 'error', error };
209
+ } finally {
210
+ if (timeout !== undefined) {
211
+ clearTimeout(timeout);
212
+ }
213
+ if (isCurrent()) {
214
+ inFlight = null;
215
+ pendingController = null;
216
+ }
217
+ }
218
+ };
219
+
220
+ return execute();
221
+ };
222
+
223
+ return {
224
+ handleBeforeQuit(event) {
225
+ if (resumePermit) {
226
+ resumePermit = false;
227
+ return undefined;
228
+ }
229
+
230
+ event.preventDefault();
231
+ if (inFlight !== null) {
232
+ return inFlight;
233
+ }
234
+
235
+ inFlight = startRequest();
236
+ return inFlight;
237
+ },
238
+ cancelPending() {
239
+ if (inFlight === null || pendingController === null) {
240
+ return false;
241
+ }
242
+
243
+ const controller = pendingController;
244
+ generation += 1;
245
+ inFlight = null;
246
+ pendingController = null;
247
+ controller.abort();
248
+ return true;
249
+ },
250
+ };
251
+ }
@@ -44,7 +44,8 @@ export interface RegisterWindowControlIpcOptions {
44
44
 
45
45
  export interface WindowControlsBridge {
46
46
  minimize(): Promise<void>;
47
- toggleMaximized(): Promise<void>;
47
+ /** Toggle the host window and resolve to its final maximized state. */
48
+ toggleMaximized(): Promise<boolean>;
48
49
  close(): Promise<void>;
49
50
  isMaximized(): Promise<boolean>;
50
51
  onMaximizedChanged(listener: (maximized: boolean) => void): () => void;
@@ -133,7 +134,8 @@ export function createWindowControlsBridge(
133
134
  await invoke(channels.minimize);
134
135
  },
135
136
  toggleMaximized: async () => {
136
- await invoke(channels.toggleMaximized);
137
+ const value = await invoke(channels.toggleMaximized);
138
+ return Boolean(value);
137
139
  },
138
140
  close: async () => {
139
141
  await invoke(channels.close);