@workbench-kit/electron-shell 0.0.2-prototype.0.2.26 → 0.0.2-prototype.0.2.28
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 +74 -4
- package/dist/lifecycle/application-quit-guard.js +163 -0
- package/dist/package.json +1 -0
- package/dist/security/open-allowlisted-external-link.js +51 -0
- package/dist/security/require-owned-window-for-sender.js +23 -0
- package/dist/window/window-controls.js +86 -0
- package/package.json +50 -3
- package/src/index.ts +9 -0
- package/src/lifecycle/application-quit-guard.ts +251 -0
- package/src/window/window-controls.ts +4 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# `@workbench-kit/electron-shell`
|
|
2
2
|
|
|
3
|
-
Electron **main-process** helpers (
|
|
4
|
-
and a typed **preload** scaffold. The package stays
|
|
5
|
-
|
|
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` +
|
|
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.
|
|
3
|
+
"version": "0.0.2-prototype.0.2.28",
|
|
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
|
-
"./
|
|
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
|
-
|
|
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);
|