@adecore/shell 0.39.0 → 0.40.1
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/dist/app-scheme.js +1 -5
- package/dist/bridge/menu.js +1 -5
- package/dist/menu.js +4 -7
- package/dist/page-keys.js +2 -4
- package/dist/theme.js +3 -8
- package/dist/updater.js +2 -4
- package/dist/window-state.js +5 -11
- package/dist/windows.js +17 -29
- package/package.json +5 -5
- package/src/app-scheme.ts +23 -5
- package/src/bridge/menu.ts +1 -5
- package/src/bridge/versions.ts +4 -0
- package/src/menu.ts +15 -7
- package/src/page-keys.ts +4 -4
- package/src/theme.ts +10 -8
- package/src/updater.ts +15 -4
- package/src/web-guards.ts +3 -0
- package/src/window-state.ts +28 -11
- package/src/windows.ts +93 -29
package/dist/app-scheme.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { realpath, readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
3
|
import { appWindowNavigation, isAppSender, originOf } from './web-guards.js';
|
|
4
|
-
/*
|
|
5
|
-
* Standard and secure give the page a real origin and a secure context (WebCrypto), fetch and CORS let it
|
|
6
|
-
* load from the scheme and talk to a backend, streaming serves big files, the code cache keeps compiled
|
|
7
|
-
* scripts between starts, and a service worker needs its own privilege.
|
|
8
|
-
*/
|
|
4
|
+
/* Standard and secure give the page a real origin and a secure context (WebCrypto); a service worker needs its own privilege. */
|
|
9
5
|
export const APP_SCHEME_PRIVILEGES = {
|
|
10
6
|
standard: true,
|
|
11
7
|
secure: true,
|
package/dist/bridge/menu.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* The application menu, built by the page from what has the focus and drawn by the shell as a native
|
|
3
|
-
* menu. The shell only knows these shapes; a click on a command comes back as its id, and the page
|
|
4
|
-
* runs it.
|
|
5
|
-
*/
|
|
1
|
+
/* The application menu, built by the page and drawn by the shell; a click on a command comes back as its id. */
|
|
6
2
|
/* The native roles the shell accepts. Each still carries a label from the page, so the whole menu is
|
|
7
3
|
in the language of the interface and not half in the system's. */
|
|
8
4
|
export const MENU_ROLES = [
|
package/dist/menu.js
CHANGED
|
@@ -39,9 +39,8 @@ export const menuTemplateOf = (spec, options) => {
|
|
|
39
39
|
};
|
|
40
40
|
const withSeparator = (items) => (items.length > 0 ? [...items, { type: 'separator' }] : []);
|
|
41
41
|
/*
|
|
42
|
-
* The menu
|
|
43
|
-
*
|
|
44
|
-
* before the page sees them, and a reload would drop the page's state without asking.
|
|
42
|
+
* The menu until the page sends its own, and while no window is open, so Quit is always there. The View
|
|
43
|
+
* menu leaves out reload and zoom: their keys are taken before the page sees them, and a reload drops its state.
|
|
45
44
|
*/
|
|
46
45
|
export const staticMenuTemplate = (options) => {
|
|
47
46
|
const platform = options.platform ?? process.platform;
|
|
@@ -78,10 +77,8 @@ export const staticMenuTemplate = (options) => {
|
|
|
78
77
|
return [appMenu, ...fileMenu, { role: 'editMenu' }, viewMenu, { role: 'windowMenu' }];
|
|
79
78
|
};
|
|
80
79
|
/*
|
|
81
|
-
* Runs a menu command in the page (`menu:run`)
|
|
82
|
-
*
|
|
83
|
-
* minimized window is shown: showing activates the app, and a pick from behind the person's work must
|
|
84
|
-
* leave it there.
|
|
80
|
+
* Runs a menu command in the page (`menu:run`). One fired by a key the page already had is dropped: its
|
|
81
|
+
* listeners answered it or let it pass. Only a minimized window is shown, since showing activates the app.
|
|
85
82
|
*/
|
|
86
83
|
export const createMenuCommands = (options) => (id, byKey) => {
|
|
87
84
|
if (byKey && !(options.keyBypassesPage?.() ?? false) && options.pageKeys.take()) {
|
package/dist/page-keys.js
CHANGED
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
export const PAGE_KEY_MS = 1000;
|
|
3
3
|
/*
|
|
4
4
|
* Whether a menu item that Electron says its accelerator fired answers a key the page already had.
|
|
5
|
-
* Electron says so for every pick that is not a mouse click,
|
|
6
|
-
*
|
|
7
|
-
* while none of its windows is key reaches the menu without the page ever seeing it. Only a key with
|
|
8
|
-
* Cmd or Ctrl counts, since the menu binds no other.
|
|
5
|
+
* Electron says so for every pick that is not a mouse click, such as through accessibility, so only
|
|
6
|
+
* a key the page saw counts. Only Cmd or Ctrl keys, since the menu binds no other.
|
|
9
7
|
*/
|
|
10
8
|
export const createPageKeys = (now = Date.now) => {
|
|
11
9
|
// One entry per key, so a held key that repeats before the menu answers the first still answers each.
|
package/dist/theme.js
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* The theme the pages report, applied to what only the shell
|
|
3
|
-
*
|
|
4
|
-
* `prefers-color-scheme` Chromium answers, which follows the app instead of the system it runs on.
|
|
5
|
-
* A new window opens in the theme the others are in.
|
|
2
|
+
* The theme the pages report, applied to what only the shell paints: every window's ground, so a reload
|
|
3
|
+
* never flashes the other theme, the native controls off macOS, and Chromium's `prefers-color-scheme`.
|
|
6
4
|
*/
|
|
7
5
|
export const createTheme = (options) => {
|
|
8
6
|
const platform = options.platform ?? process.platform;
|
|
9
7
|
let current = null;
|
|
10
|
-
|
|
11
|
-
* The colors a page reports win over the ones per theme, so the controls stay on its ground after it
|
|
12
|
-
* changed and a window opened later starts on it too. Only the two colors are taken from what crossed IPC.
|
|
13
|
-
*/
|
|
8
|
+
// The colors a page reports win over the ones per theme. Only the two colors are taken from what crossed IPC.
|
|
14
9
|
const overlayOf = (theme) => {
|
|
15
10
|
if (!options.overlay) {
|
|
16
11
|
return undefined;
|
package/dist/updater.js
CHANGED
|
@@ -37,8 +37,7 @@ export const createUpdater = (options) => {
|
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
39
|
const check = async () => {
|
|
40
|
-
// Nothing to learn while a check or a download runs,
|
|
41
|
-
// does not get better for being asked about again.
|
|
40
|
+
// Nothing to learn while a check or a download runs, or while a build waits to be installed.
|
|
42
41
|
if (!updater || state.status === 'checking' || state.status === 'downloading' || state.status === 'ready') {
|
|
43
42
|
return;
|
|
44
43
|
}
|
|
@@ -59,8 +58,7 @@ export const createUpdater = (options) => {
|
|
|
59
58
|
}
|
|
60
59
|
try {
|
|
61
60
|
const loaded = options.load();
|
|
62
|
-
// The page
|
|
63
|
-
// behind the back of someone who turned it off.
|
|
61
|
+
// The page sends its preference before the first check, so nothing downloads for someone who turned it off.
|
|
64
62
|
loaded.autoDownload = false;
|
|
65
63
|
loaded.on('checking-for-update', () => setState({ status: 'checking', error: null, errorCode: null }));
|
|
66
64
|
loaded.on('update-available', (info) => setState({ status: 'available', version: info.version, error: null, errorCode: null }));
|
package/dist/window-state.js
CHANGED
|
@@ -6,10 +6,8 @@ const overlap = (a, b) => ({
|
|
|
6
6
|
height: Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y))
|
|
7
7
|
});
|
|
8
8
|
/*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* them off screen, and then the window opens at its default size, which Electron centers. Null bounds
|
|
12
|
-
* mean nothing was saved.
|
|
9
|
+
* Saved bounds stand while a display still shows enough of them to grab, shrunk to its work area;
|
|
10
|
+
* otherwise, or for null, the window opens at its default size, which Electron centers.
|
|
13
11
|
*/
|
|
14
12
|
export const fitBounds = (saved, displays, defaults) => {
|
|
15
13
|
if (saved) {
|
|
@@ -97,10 +95,7 @@ const parse = (text) => {
|
|
|
97
95
|
}
|
|
98
96
|
return windows;
|
|
99
97
|
};
|
|
100
|
-
/*
|
|
101
|
-
* Keeps where each window stood, by a key the app chooses, so it opens there again: the size, the
|
|
102
|
-
* place, maximized and full screen. Written a moment after a window stops moving and when it closes.
|
|
103
|
-
*/
|
|
98
|
+
/* Keeps where each window stood by a key the app chooses, written a moment after it stops moving and when it closes. */
|
|
104
99
|
export const createWindowState = (options) => {
|
|
105
100
|
const delayMs = options.delayMs ?? 500;
|
|
106
101
|
const later = options.setTimeout ?? ((run, ms) => setTimeout(run, ms));
|
|
@@ -127,9 +122,8 @@ export const createWindowState = (options) => {
|
|
|
127
122
|
/* The bounds to construct the window with. `fallback` stands in for a key that has none saved, such as a spot beside another window. */
|
|
128
123
|
bounds: (key, fallback = null) => fitBounds(windows.get(key) ?? fallback, options.displays(), options.defaults),
|
|
129
124
|
/*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* given as a function is asked at every change, for a window that comes to show something else.
|
|
125
|
+
* Call right after constructing the window, before it is shown; it is maximized or put in full screen
|
|
126
|
+
* as it was left. A key given as a function is asked at every change, for a window that shows another.
|
|
133
127
|
*/
|
|
134
128
|
track: (key, window) => {
|
|
135
129
|
const keyNow = typeof key === 'function' ? key : () => key;
|
package/dist/windows.js
CHANGED
|
@@ -53,7 +53,12 @@ export const createWindows = (options) => {
|
|
|
53
53
|
let confirming = false;
|
|
54
54
|
let installing = false;
|
|
55
55
|
const later = options.setTimeout ?? ((run, ms) => setTimeout(run, ms));
|
|
56
|
-
const
|
|
56
|
+
const isAlive = (entry) => entry !== undefined && !entry.window.isDestroyed();
|
|
57
|
+
const alive = () => [...entries.values()].filter(isAlive);
|
|
58
|
+
const frontToBack = () => order
|
|
59
|
+
.toReversed()
|
|
60
|
+
.map((id) => entries.get(id))
|
|
61
|
+
.filter(isAlive);
|
|
57
62
|
const writeSession = () => {
|
|
58
63
|
if (quitting) {
|
|
59
64
|
return;
|
|
@@ -61,21 +66,13 @@ export const createWindows = (options) => {
|
|
|
61
66
|
const shown = order.map((id) => entries.get(id));
|
|
62
67
|
const keys = shown.map((entry) => entry?.key ?? null);
|
|
63
68
|
const states = shown.map((entry) => entry?.state ?? null);
|
|
64
|
-
const file = states.some((state) => state !== null) ? {
|
|
69
|
+
const file = { version: SESSION_VERSION, windows: keys, ...(states.some((state) => state !== null) ? { states } : {}) };
|
|
65
70
|
options.session.write(`${JSON.stringify(file, null, 2)}\n`);
|
|
66
71
|
};
|
|
67
72
|
const toFront = (id) => {
|
|
68
73
|
order = [...order.filter((other) => other !== id), id];
|
|
69
74
|
};
|
|
70
|
-
const focused = () =>
|
|
71
|
-
for (let i = order.length - 1; i >= 0; i--) {
|
|
72
|
-
const entry = entries.get(order[i]);
|
|
73
|
-
if (entry && !entry.window.isDestroyed()) {
|
|
74
|
-
return entry.window;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return null;
|
|
78
|
-
};
|
|
75
|
+
const focused = () => frontToBack()[0]?.window ?? null;
|
|
79
76
|
const holderOf = (key) => alive().find((entry) => entry.key === key || entry.held.includes(key)) ?? null;
|
|
80
77
|
/* Takes a key out of the window that has it, shown or kept. */
|
|
81
78
|
const release = (entry, key) => {
|
|
@@ -275,19 +272,13 @@ export const createWindows = (options) => {
|
|
|
275
272
|
/* The window that shows or keeps a key, if any. */
|
|
276
273
|
holderOf: (key) => holderOf(key)?.window ?? null,
|
|
277
274
|
/* The app window under a point on the screen, the one last in front first, leaving out `except`. */
|
|
278
|
-
windowAt: (point, except) => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (!entry || entry.window.isDestroyed() || entry.window === except || entry.window.isMinimized() || !entry.window.isVisible()) {
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
const bounds = entry.window.getBounds();
|
|
285
|
-
if (point.x >= bounds.x && point.x < bounds.x + bounds.width && point.y >= bounds.y && point.y < bounds.y + bounds.height) {
|
|
286
|
-
return entry.window;
|
|
287
|
-
}
|
|
275
|
+
windowAt: (point, except) => frontToBack().find(({ window }) => {
|
|
276
|
+
if (window === except || window.isMinimized() || !window.isVisible()) {
|
|
277
|
+
return false;
|
|
288
278
|
}
|
|
289
|
-
|
|
290
|
-
|
|
279
|
+
const bounds = window.getBounds();
|
|
280
|
+
return point.x >= bounds.x && point.x < bounds.x + bounds.width && point.y >= bounds.y && point.y < bounds.y + bounds.height;
|
|
281
|
+
})?.window ?? null,
|
|
291
282
|
/*
|
|
292
283
|
* What a window's page keeps of itself across a quit, opaque to the shell, such as the tabs it
|
|
293
284
|
* had. The session hands it back to `create` for that window; null keeps nothing.
|
|
@@ -339,12 +330,9 @@ export const createWindows = (options) => {
|
|
|
339
330
|
}
|
|
340
331
|
},
|
|
341
332
|
/*
|
|
342
|
-
*
|
|
343
|
-
* the
|
|
344
|
-
*
|
|
345
|
-
* quit, and off macOS the app quits with its last window while that window stays in the session.
|
|
346
|
-
* With `confirmQuit`, a quit waits for its answer. Call it before `app.whenReady()`, and
|
|
347
|
-
* `restore()` once ready.
|
|
333
|
+
* Follows the app's events: a second instance raises the window in front, the dock icon with no
|
|
334
|
+
* window reopens the last session, a quit keeps its windows in the session, and off macOS the app
|
|
335
|
+
* quits with its last window. Call it before `app.whenReady()`, and `restore()` once ready.
|
|
348
336
|
*/
|
|
349
337
|
attach: (app, attachOptions = {}) => {
|
|
350
338
|
const { confirmQuit } = attachOptions;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.1",
|
|
4
4
|
"description": "The main process of an Electron app: its menu, its updater and the guards around its page.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
6
|
"author": "Bas Milius",
|
|
@@ -50,10 +50,10 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@types/bun": "^1.4.
|
|
54
|
-
"@types/node": "^24.
|
|
55
|
-
"electron": "^
|
|
56
|
-
"electron-updater": "^6.8.
|
|
53
|
+
"@types/bun": "^1.4.4",
|
|
54
|
+
"@types/node": "^24.19.2",
|
|
55
|
+
"electron": "^45.0.0",
|
|
56
|
+
"electron-updater": "^6.8.11",
|
|
57
57
|
"typescript": "^7.0.2"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/app-scheme.ts
CHANGED
|
@@ -3,11 +3,7 @@ import { extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
|
3
3
|
import type { CustomScheme } from 'electron';
|
|
4
4
|
import { appWindowNavigation, isAppSender, originOf, type NavigationVerdict, type SenderFrame } from './web-guards.ts';
|
|
5
5
|
|
|
6
|
-
/*
|
|
7
|
-
* Standard and secure give the page a real origin and a secure context (WebCrypto), fetch and CORS let it
|
|
8
|
-
* load from the scheme and talk to a backend, streaming serves big files, the code cache keeps compiled
|
|
9
|
-
* scripts between starts, and a service worker needs its own privilege.
|
|
10
|
-
*/
|
|
6
|
+
/* Standard and secure give the page a real origin and a secure context (WebCrypto); a service worker needs its own privilege. */
|
|
11
7
|
export const APP_SCHEME_PRIVILEGES: NonNullable<CustomScheme['privileges']> = {
|
|
12
8
|
standard: true,
|
|
13
9
|
secure: true,
|
|
@@ -102,27 +98,35 @@ const readWhole = async (file: string): Promise<Response> => new Response(new Ui
|
|
|
102
98
|
/* The segments of a path as the URL carries them, decoded, or null when one could climb out or means nothing on its own. */
|
|
103
99
|
export const segmentsOf = (pathname: string): string[] | null => {
|
|
104
100
|
const path = pathname.replace(/^\/+/, '');
|
|
101
|
+
|
|
105
102
|
if (path === '') {
|
|
106
103
|
return [];
|
|
107
104
|
}
|
|
105
|
+
|
|
108
106
|
const segments: string[] = [];
|
|
107
|
+
|
|
109
108
|
for (const raw of path.split('/')) {
|
|
110
109
|
let segment: string;
|
|
110
|
+
|
|
111
111
|
try {
|
|
112
112
|
segment = decodeURIComponent(raw);
|
|
113
113
|
} catch {
|
|
114
114
|
return null;
|
|
115
115
|
}
|
|
116
|
+
|
|
116
117
|
if (segment === '' || segment === '.' || segment === '..' || /[/\\\0]/.test(segment)) {
|
|
117
118
|
return null;
|
|
118
119
|
}
|
|
120
|
+
|
|
119
121
|
segments.push(segment);
|
|
120
122
|
}
|
|
123
|
+
|
|
121
124
|
return segments;
|
|
122
125
|
};
|
|
123
126
|
|
|
124
127
|
const isInside = (root: string, path: string): boolean => {
|
|
125
128
|
const between = relative(root, path);
|
|
129
|
+
|
|
126
130
|
return between !== '' && !between.startsWith('..') && !isAbsolute(between);
|
|
127
131
|
};
|
|
128
132
|
|
|
@@ -144,36 +148,46 @@ export function createAppScheme(options: AppSchemeOptions): AppScheme {
|
|
|
144
148
|
|
|
145
149
|
const serveFile = async (root: string, file: string, request: Request, headers: Record<string, string> = {}): Promise<Response> => {
|
|
146
150
|
let found: string;
|
|
151
|
+
|
|
147
152
|
try {
|
|
148
153
|
const [realRoot, realFile] = await Promise.all([realpath(resolve(root)), realpath(resolve(file))]);
|
|
154
|
+
|
|
149
155
|
if (!isInside(realRoot, realFile) || !(await stat(realFile)).isFile()) {
|
|
150
156
|
return notFound();
|
|
151
157
|
}
|
|
158
|
+
|
|
152
159
|
found = realFile;
|
|
153
160
|
} catch {
|
|
154
161
|
return notFound();
|
|
155
162
|
}
|
|
163
|
+
|
|
156
164
|
const response = await read(found, request);
|
|
157
165
|
const merged = new Headers(response.headers);
|
|
158
166
|
const type = CONTENT_TYPES[extname(found).toLowerCase()];
|
|
167
|
+
|
|
159
168
|
if (type !== undefined) {
|
|
160
169
|
merged.set('content-type', type);
|
|
161
170
|
}
|
|
171
|
+
|
|
162
172
|
for (const [name, value] of Object.entries(headers)) {
|
|
163
173
|
merged.set(name, value);
|
|
164
174
|
}
|
|
175
|
+
|
|
165
176
|
return new Response(response.body, { status: response.status, statusText: response.statusText, headers: merged });
|
|
166
177
|
};
|
|
167
178
|
|
|
168
179
|
const serveFolder = (folder: SchemeFolder, request: Request, address: URL): Promise<Response> | Response => {
|
|
169
180
|
const segments = segmentsOf(address.pathname);
|
|
181
|
+
|
|
170
182
|
if (segments === null) {
|
|
171
183
|
return notFound();
|
|
172
184
|
}
|
|
185
|
+
|
|
173
186
|
// A missing chunk with an extension stays a 404, so a stale one fails as a load error rather than as the page.
|
|
174
187
|
const page = segments.length === 0 || (folder.routes === true && extname(segments.at(-1)!) === '');
|
|
175
188
|
const file = page ? join(folder.root, 'index.html') : join(folder.root, ...segments);
|
|
176
189
|
const headers = typeof folder.headers === 'function' ? folder.headers(file) : folder.headers;
|
|
190
|
+
|
|
177
191
|
return serveFile(folder.root, file, request, headers);
|
|
178
192
|
};
|
|
179
193
|
|
|
@@ -184,15 +198,19 @@ export function createAppScheme(options: AppSchemeOptions): AppScheme {
|
|
|
184
198
|
privileged: { scheme, privileges: options.privileges ?? APP_SCHEME_PRIVILEGES },
|
|
185
199
|
handle: async (request) => {
|
|
186
200
|
let address: URL;
|
|
201
|
+
|
|
187
202
|
try {
|
|
188
203
|
address = new URL(request.url);
|
|
189
204
|
} catch {
|
|
190
205
|
return notFound();
|
|
191
206
|
}
|
|
207
|
+
|
|
192
208
|
const host = address.protocol === `${scheme}:` ? hosts.get(address.host.toLowerCase()) : undefined;
|
|
209
|
+
|
|
193
210
|
if (host === undefined) {
|
|
194
211
|
return notFound();
|
|
195
212
|
}
|
|
213
|
+
|
|
196
214
|
return typeof host === 'function' ? host(request, address) : serveFolder(host, request, address);
|
|
197
215
|
},
|
|
198
216
|
serveFile,
|
package/src/bridge/menu.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* The application menu, built by the page from what has the focus and drawn by the shell as a native
|
|
3
|
-
* menu. The shell only knows these shapes; a click on a command comes back as its id, and the page
|
|
4
|
-
* runs it.
|
|
5
|
-
*/
|
|
1
|
+
/* The application menu, built by the page and drawn by the shell; a click on a command comes back as its id. */
|
|
6
2
|
|
|
7
3
|
/* The native roles the shell accepts. Each still carries a label from the page, so the whole menu is
|
|
8
4
|
in the language of the interface and not half in the system's. */
|
package/src/bridge/versions.ts
CHANGED
|
@@ -12,14 +12,18 @@ export const isVersion = (version: string): boolean => SEMVER.test(version);
|
|
|
12
12
|
export const compareVersions = (a: string, b: string): number => {
|
|
13
13
|
const left = SEMVER.exec(a);
|
|
14
14
|
const right = SEMVER.exec(b);
|
|
15
|
+
|
|
15
16
|
if (!left || !right) {
|
|
16
17
|
return (left ? 1 : 0) - (right ? 1 : 0);
|
|
17
18
|
}
|
|
19
|
+
|
|
18
20
|
for (let i = 1; i <= 3; i++) {
|
|
19
21
|
const difference = Number(left[i]) - Number(right[i]);
|
|
22
|
+
|
|
20
23
|
if (difference !== 0) {
|
|
21
24
|
return difference;
|
|
22
25
|
}
|
|
23
26
|
}
|
|
27
|
+
|
|
24
28
|
return 0;
|
|
25
29
|
};
|
package/src/menu.ts
CHANGED
|
@@ -18,7 +18,9 @@ export const menuTemplateOf = <Action extends string>(spec: MenuSpec<Action>, op
|
|
|
18
18
|
if (!Array.isArray(spec?.menus)) {
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
|
+
|
|
21
22
|
const platform = options.platform ?? process.platform;
|
|
23
|
+
|
|
22
24
|
const itemOf = (node: MenuNode<Action>): MenuItemConstructorOptions | null => {
|
|
23
25
|
switch (node.kind) {
|
|
24
26
|
case 'separator':
|
|
@@ -42,8 +44,10 @@ export const menuTemplateOf = <Action extends string>(spec: MenuSpec<Action>, op
|
|
|
42
44
|
};
|
|
43
45
|
}
|
|
44
46
|
};
|
|
47
|
+
|
|
45
48
|
const itemsOf = (nodes: readonly MenuNode<Action>[]): MenuItemConstructorOptions[] =>
|
|
46
49
|
nodes.map(itemOf).filter((item): item is MenuItemConstructorOptions => item !== null);
|
|
50
|
+
|
|
47
51
|
return spec.menus.map((menu) => ({
|
|
48
52
|
label: menu.label,
|
|
49
53
|
...(menu.role !== undefined && MENU_BAR_ROLES.includes(menu.role) ? { role: menu.role } : {}),
|
|
@@ -66,15 +70,15 @@ export interface StaticMenuOptions {
|
|
|
66
70
|
const withSeparator = (items: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] => (items.length > 0 ? [...items, { type: 'separator' }] : []);
|
|
67
71
|
|
|
68
72
|
/*
|
|
69
|
-
* The menu
|
|
70
|
-
*
|
|
71
|
-
* before the page sees them, and a reload would drop the page's state without asking.
|
|
73
|
+
* The menu until the page sends its own, and while no window is open, so Quit is always there. The View
|
|
74
|
+
* menu leaves out reload and zoom: their keys are taken before the page sees them, and a reload drops its state.
|
|
72
75
|
*/
|
|
73
76
|
export const staticMenuTemplate = (options: StaticMenuOptions): MenuItemConstructorOptions[] => {
|
|
74
77
|
const platform = options.platform ?? process.platform;
|
|
75
78
|
const appItems = options.appItems ?? [];
|
|
76
79
|
const quitItems = options.quitItems ?? [];
|
|
77
80
|
const fileItems = options.fileItems ?? [];
|
|
81
|
+
|
|
78
82
|
const appMenu: MenuItemConstructorOptions =
|
|
79
83
|
platform === 'darwin'
|
|
80
84
|
? {
|
|
@@ -94,7 +98,9 @@ export const staticMenuTemplate = (options: StaticMenuOptions): MenuItemConstruc
|
|
|
94
98
|
: fileItems.length > 0 || quitItems.length > 0
|
|
95
99
|
? { label: 'File', submenu: [...withSeparator(fileItems), ...withSeparator(quitItems), { role: 'quit' }] }
|
|
96
100
|
: { role: 'fileMenu' };
|
|
101
|
+
|
|
97
102
|
const fileMenu: MenuItemConstructorOptions[] = platform === 'darwin' && fileItems.length > 0 ? [{ label: 'File', submenu: fileItems }] : [];
|
|
103
|
+
|
|
98
104
|
const viewMenu: MenuItemConstructorOptions = {
|
|
99
105
|
label: 'View',
|
|
100
106
|
submenu: [
|
|
@@ -103,6 +109,7 @@ export const staticMenuTemplate = (options: StaticMenuOptions): MenuItemConstruc
|
|
|
103
109
|
{ role: 'togglefullscreen' }
|
|
104
110
|
]
|
|
105
111
|
};
|
|
112
|
+
|
|
106
113
|
return [appMenu, ...fileMenu, { role: 'editMenu' }, viewMenu, { role: 'windowMenu' }];
|
|
107
114
|
};
|
|
108
115
|
|
|
@@ -122,10 +129,8 @@ export interface MenuCommandOptions {
|
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
/*
|
|
125
|
-
* Runs a menu command in the page (`menu:run`)
|
|
126
|
-
*
|
|
127
|
-
* minimized window is shown: showing activates the app, and a pick from behind the person's work must
|
|
128
|
-
* leave it there.
|
|
132
|
+
* Runs a menu command in the page (`menu:run`). One fired by a key the page already had is dropped: its
|
|
133
|
+
* listeners answered it or let it pass. Only a minimized window is shown, since showing activates the app.
|
|
129
134
|
*/
|
|
130
135
|
export const createMenuCommands =
|
|
131
136
|
(options: MenuCommandOptions) =>
|
|
@@ -133,9 +138,12 @@ export const createMenuCommands =
|
|
|
133
138
|
if (byKey && !(options.keyBypassesPage?.() ?? false) && options.pageKeys.take()) {
|
|
134
139
|
return;
|
|
135
140
|
}
|
|
141
|
+
|
|
136
142
|
const window = options.window();
|
|
143
|
+
|
|
137
144
|
if (window?.isMinimized()) {
|
|
138
145
|
window.show();
|
|
139
146
|
}
|
|
147
|
+
|
|
140
148
|
window?.webContents.send('menu:run', id);
|
|
141
149
|
};
|
package/src/page-keys.ts
CHANGED
|
@@ -10,14 +10,13 @@ export const PAGE_KEY_MS = 1000;
|
|
|
10
10
|
|
|
11
11
|
/*
|
|
12
12
|
* Whether a menu item that Electron says its accelerator fired answers a key the page already had.
|
|
13
|
-
* Electron says so for every pick that is not a mouse click,
|
|
14
|
-
*
|
|
15
|
-
* while none of its windows is key reaches the menu without the page ever seeing it. Only a key with
|
|
16
|
-
* Cmd or Ctrl counts, since the menu binds no other.
|
|
13
|
+
* Electron says so for every pick that is not a mouse click, such as through accessibility, so only
|
|
14
|
+
* a key the page saw counts. Only Cmd or Ctrl keys, since the menu binds no other.
|
|
17
15
|
*/
|
|
18
16
|
export const createPageKeys = (now: () => number = Date.now) => {
|
|
19
17
|
// One entry per key, so a held key that repeats before the menu answers the first still answers each.
|
|
20
18
|
let seen: number[] = [];
|
|
19
|
+
|
|
21
20
|
return {
|
|
22
21
|
saw: (key: PageKey): void => {
|
|
23
22
|
if (key.type === 'keyDown' && (key.meta || key.control)) {
|
|
@@ -27,6 +26,7 @@ export const createPageKeys = (now: () => number = Date.now) => {
|
|
|
27
26
|
take: (): boolean => {
|
|
28
27
|
const since = now() - PAGE_KEY_MS;
|
|
29
28
|
seen = seen.filter((at) => at >= since);
|
|
29
|
+
|
|
30
30
|
return seen.shift() !== undefined;
|
|
31
31
|
}
|
|
32
32
|
};
|
package/src/theme.ts
CHANGED
|
@@ -20,24 +20,21 @@ export interface ThemeOptions {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/*
|
|
23
|
-
* The theme the pages report, applied to what only the shell
|
|
24
|
-
*
|
|
25
|
-
* `prefers-color-scheme` Chromium answers, which follows the app instead of the system it runs on.
|
|
26
|
-
* A new window opens in the theme the others are in.
|
|
23
|
+
* The theme the pages report, applied to what only the shell paints: every window's ground, so a reload
|
|
24
|
+
* never flashes the other theme, the native controls off macOS, and Chromium's `prefers-color-scheme`.
|
|
27
25
|
*/
|
|
28
26
|
export const createTheme = (options: ThemeOptions) => {
|
|
29
27
|
const platform = options.platform ?? process.platform;
|
|
30
28
|
let current: ThemeState | null = null;
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
* The colors a page reports win over the ones per theme, so the controls stay on its ground after it
|
|
34
|
-
* changed and a window opened later starts on it too. Only the two colors are taken from what crossed IPC.
|
|
35
|
-
*/
|
|
30
|
+
// The colors a page reports win over the ones per theme. Only the two colors are taken from what crossed IPC.
|
|
36
31
|
const overlayOf = (theme: Pick<ThemeState, 'resolved' | 'overlay'>) => {
|
|
37
32
|
if (!options.overlay) {
|
|
38
33
|
return undefined;
|
|
39
34
|
}
|
|
35
|
+
|
|
40
36
|
const { color, symbolColor } = theme.overlay ?? options.overlay.colors[theme.resolved];
|
|
37
|
+
|
|
41
38
|
return { height: options.overlay.height, color, symbolColor };
|
|
42
39
|
};
|
|
43
40
|
|
|
@@ -46,13 +43,16 @@ export const createTheme = (options: ThemeOptions) => {
|
|
|
46
43
|
current = theme;
|
|
47
44
|
options.nativeTheme.themeSource = theme.followsSystem ? 'system' : theme.resolved;
|
|
48
45
|
const overlay = platform === 'darwin' ? undefined : overlayOf(theme);
|
|
46
|
+
|
|
49
47
|
for (const window of options.windows()) {
|
|
50
48
|
if (window.isDestroyed()) {
|
|
51
49
|
continue;
|
|
52
50
|
}
|
|
51
|
+
|
|
53
52
|
if (overlay) {
|
|
54
53
|
window.setTitleBarOverlay(overlay);
|
|
55
54
|
}
|
|
55
|
+
|
|
56
56
|
window.setBackgroundColor(theme.background);
|
|
57
57
|
}
|
|
58
58
|
},
|
|
@@ -62,10 +62,12 @@ export const createTheme = (options: ThemeOptions) => {
|
|
|
62
62
|
/* The constructor options of a new window: its own chrome and the ground the others have. Dark until a page says otherwise. */
|
|
63
63
|
windowOptions: (): BrowserWindowConstructorOptions => {
|
|
64
64
|
const overlay = overlayOf(current ?? { resolved: 'dark' });
|
|
65
|
+
|
|
65
66
|
const chrome: BrowserWindowConstructorOptions =
|
|
66
67
|
platform === 'darwin'
|
|
67
68
|
? { titleBarStyle: 'hiddenInset', trafficLightPosition: options.trafficLights ?? { x: 17, y: 17 } }
|
|
68
69
|
: { titleBarStyle: 'hidden', ...(overlay ? { titleBarOverlay: overlay } : {}) };
|
|
70
|
+
|
|
69
71
|
return { ...chrome, backgroundColor: current?.background ?? options.background };
|
|
70
72
|
}
|
|
71
73
|
};
|
package/src/updater.ts
CHANGED
|
@@ -11,9 +11,11 @@ export const UPDATE_INTERVAL_MS = 60 * 60 * 1000;
|
|
|
11
11
|
*/
|
|
12
12
|
export const describeUpdateError = (message: string): { code: UpdateErrorCode; detail: string | null } => {
|
|
13
13
|
const first = message.split('\n')[0]?.trim();
|
|
14
|
+
|
|
14
15
|
if (!first) {
|
|
15
16
|
return { code: 'failed', detail: null };
|
|
16
17
|
}
|
|
18
|
+
|
|
17
19
|
return {
|
|
18
20
|
code: first.startsWith('404') ? 'no-feed' : 'failed',
|
|
19
21
|
detail: first.length > 200 ? `${first.slice(0, 200)}…` : first
|
|
@@ -65,12 +67,13 @@ export const createUpdater = (options: UpdaterOptions) => {
|
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
const check = async (): Promise<void> => {
|
|
68
|
-
// Nothing to learn while a check or a download runs,
|
|
69
|
-
// does not get better for being asked about again.
|
|
70
|
+
// Nothing to learn while a check or a download runs, or while a build waits to be installed.
|
|
70
71
|
if (!updater || state.status === 'checking' || state.status === 'downloading' || state.status === 'ready') {
|
|
71
72
|
return;
|
|
72
73
|
}
|
|
74
|
+
|
|
73
75
|
options.beforeCheck?.();
|
|
76
|
+
|
|
74
77
|
try {
|
|
75
78
|
await updater.checkForUpdates();
|
|
76
79
|
} catch (e) {
|
|
@@ -86,21 +89,23 @@ export const createUpdater = (options: UpdaterOptions) => {
|
|
|
86
89
|
if (!options.packaged) {
|
|
87
90
|
return;
|
|
88
91
|
}
|
|
92
|
+
|
|
89
93
|
try {
|
|
90
94
|
const loaded = options.load();
|
|
91
|
-
// The page
|
|
92
|
-
// behind the back of someone who turned it off.
|
|
95
|
+
// The page sends its preference before the first check, so nothing downloads for someone who turned it off.
|
|
93
96
|
loaded.autoDownload = false;
|
|
94
97
|
loaded.on('checking-for-update', () => setState({ status: 'checking', error: null, errorCode: null }));
|
|
95
98
|
loaded.on('update-available', (info) => setState({ status: 'available', version: info.version, error: null, errorCode: null }));
|
|
96
99
|
loaded.on('update-not-available', () => setState({ status: 'current', version: undefined, error: null, errorCode: null }));
|
|
97
100
|
loaded.on('download-progress', (progress) => setState({ status: 'downloading', percent: progress.percent }));
|
|
98
101
|
loaded.on('update-downloaded', (info) => setState({ status: 'ready', version: info.version, percent: 100 }));
|
|
102
|
+
|
|
99
103
|
loaded.on('error', (e) => {
|
|
100
104
|
installFailed();
|
|
101
105
|
const { code, detail } = describeUpdateError(e.message);
|
|
102
106
|
setState({ status: 'error', error: detail, errorCode: code });
|
|
103
107
|
});
|
|
108
|
+
|
|
104
109
|
updater = loaded;
|
|
105
110
|
setState({ status: 'idle' });
|
|
106
111
|
} catch (e) {
|
|
@@ -114,6 +119,7 @@ export const createUpdater = (options: UpdaterOptions) => {
|
|
|
114
119
|
if (!updater) {
|
|
115
120
|
return;
|
|
116
121
|
}
|
|
122
|
+
|
|
117
123
|
updater.autoDownload = autoDownload === true;
|
|
118
124
|
timer ??= every(() => void check(), UPDATE_INTERVAL_MS);
|
|
119
125
|
},
|
|
@@ -124,6 +130,7 @@ export const createUpdater = (options: UpdaterOptions) => {
|
|
|
124
130
|
if (!updater) {
|
|
125
131
|
return;
|
|
126
132
|
}
|
|
133
|
+
|
|
127
134
|
try {
|
|
128
135
|
await updater.downloadUpdate();
|
|
129
136
|
} catch (e) {
|
|
@@ -136,14 +143,18 @@ export const createUpdater = (options: UpdaterOptions) => {
|
|
|
136
143
|
if (!updater || state.status !== 'ready') {
|
|
137
144
|
return false;
|
|
138
145
|
}
|
|
146
|
+
|
|
139
147
|
installing = true;
|
|
140
148
|
options.onQuit?.(true);
|
|
149
|
+
|
|
141
150
|
try {
|
|
142
151
|
updater.quitAndInstall();
|
|
152
|
+
|
|
143
153
|
return true;
|
|
144
154
|
} catch (e) {
|
|
145
155
|
log('Update install failed', e);
|
|
146
156
|
installFailed();
|
|
157
|
+
|
|
147
158
|
return false;
|
|
148
159
|
}
|
|
149
160
|
}
|
package/src/web-guards.ts
CHANGED
|
@@ -11,9 +11,11 @@ export const originOf = (url: string, schemes: readonly string[] = []): string |
|
|
|
11
11
|
try {
|
|
12
12
|
const parsed = new URL(url);
|
|
13
13
|
const scheme = parsed.protocol.slice(0, -1);
|
|
14
|
+
|
|
14
15
|
if (schemes.includes(scheme)) {
|
|
15
16
|
return parsed.host ? `${scheme}://${parsed.host.toLowerCase()}` : null;
|
|
16
17
|
}
|
|
18
|
+
|
|
17
19
|
return parsed.origin === 'null' ? null : parsed.origin;
|
|
18
20
|
} catch {
|
|
19
21
|
return null;
|
|
@@ -38,6 +40,7 @@ export const appWindowNavigation = (url: string, appOrigin: string, schemes: rea
|
|
|
38
40
|
if (isAppUrl(url, appOrigin, schemes)) {
|
|
39
41
|
return 'allow';
|
|
40
42
|
}
|
|
43
|
+
|
|
41
44
|
return isWebLink(url) ? 'external' : 'refuse';
|
|
42
45
|
};
|
|
43
46
|
|
package/src/window-state.ts
CHANGED
|
@@ -30,21 +30,22 @@ const overlap = (a: Rectangle, b: Rectangle): WindowSize => ({
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
/*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* them off screen, and then the window opens at its default size, which Electron centers. Null bounds
|
|
36
|
-
* mean nothing was saved.
|
|
33
|
+
* Saved bounds stand while a display still shows enough of them to grab, shrunk to its work area;
|
|
34
|
+
* otherwise, or for null, the window opens at its default size, which Electron centers.
|
|
37
35
|
*/
|
|
38
36
|
export const fitBounds = (saved: Rectangle | null, displays: readonly WindowDisplay[], defaults: WindowSize): Partial<Rectangle> & WindowSize => {
|
|
39
37
|
if (saved) {
|
|
40
38
|
const display = displays.find(({ workArea }) => {
|
|
41
39
|
const shared = overlap(saved, workArea);
|
|
40
|
+
|
|
42
41
|
return shared.width >= Math.min(VISIBLE_EDGE.width, saved.width) && shared.height >= Math.min(VISIBLE_EDGE.height, saved.height);
|
|
43
42
|
});
|
|
43
|
+
|
|
44
44
|
if (display) {
|
|
45
45
|
const area = display.workArea;
|
|
46
46
|
const width = Math.min(saved.width, area.width);
|
|
47
47
|
const height = Math.min(saved.height, area.height);
|
|
48
|
+
|
|
48
49
|
return {
|
|
49
50
|
x: Math.min(Math.max(saved.x, area.x), area.x + area.width - width),
|
|
50
51
|
y: Math.min(Math.max(saved.y, area.y), area.y + area.height - height),
|
|
@@ -53,6 +54,7 @@ export const fitBounds = (saved: Rectangle | null, displays: readonly WindowDisp
|
|
|
53
54
|
};
|
|
54
55
|
}
|
|
55
56
|
}
|
|
57
|
+
|
|
56
58
|
return { width: defaults.width, height: defaults.height };
|
|
57
59
|
};
|
|
58
60
|
|
|
@@ -79,11 +81,13 @@ export function renameRetrying(from: string, to: string, rename: (from: string,
|
|
|
79
81
|
for (let attempt = 0; ; attempt++) {
|
|
80
82
|
try {
|
|
81
83
|
rename(from, to);
|
|
84
|
+
|
|
82
85
|
return;
|
|
83
86
|
} catch (e) {
|
|
84
87
|
if (!isTransient(e) || attempt >= RENAME_RETRIES) {
|
|
85
88
|
throw e;
|
|
86
89
|
}
|
|
90
|
+
|
|
87
91
|
// A synchronous wait: the write runs in a timer of the main process and has no promise to wait on.
|
|
88
92
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, RENAME_RETRY_MS);
|
|
89
93
|
}
|
|
@@ -122,21 +126,28 @@ const isSaved = (value: unknown): value is SavedWindow => {
|
|
|
122
126
|
if (typeof value !== 'object' || value === null) {
|
|
123
127
|
return false;
|
|
124
128
|
}
|
|
129
|
+
|
|
125
130
|
const saved = value as Record<string, unknown>;
|
|
131
|
+
|
|
126
132
|
const whole = (key: string): boolean => Number.isInteger(saved[key]);
|
|
133
|
+
|
|
127
134
|
return whole('x') && whole('y') && whole('width') && whole('height') && (saved.width as number) > 0 && (saved.height as number) > 0;
|
|
128
135
|
};
|
|
129
136
|
|
|
130
137
|
const parse = (text: string | null): Map<string, SavedWindow> => {
|
|
131
138
|
const windows = new Map<string, SavedWindow>();
|
|
139
|
+
|
|
132
140
|
if (text === null) {
|
|
133
141
|
return windows;
|
|
134
142
|
}
|
|
143
|
+
|
|
135
144
|
try {
|
|
136
145
|
const file = JSON.parse(text) as { version?: unknown; windows?: Record<string, unknown> };
|
|
146
|
+
|
|
137
147
|
if (file.version !== FILE_VERSION || typeof file.windows !== 'object' || file.windows === null) {
|
|
138
148
|
return windows;
|
|
139
149
|
}
|
|
150
|
+
|
|
140
151
|
for (const [key, value] of Object.entries(file.windows)) {
|
|
141
152
|
if (isSaved(value)) {
|
|
142
153
|
windows.set(key, { ...value, maximized: value.maximized === true, fullScreen: value.fullScreen === true });
|
|
@@ -145,13 +156,11 @@ const parse = (text: string | null): Map<string, SavedWindow> => {
|
|
|
145
156
|
} catch {
|
|
146
157
|
// A file that does not parse is a first start: every window opens at its default.
|
|
147
158
|
}
|
|
159
|
+
|
|
148
160
|
return windows;
|
|
149
161
|
};
|
|
150
162
|
|
|
151
|
-
/*
|
|
152
|
-
* Keeps where each window stood, by a key the app chooses, so it opens there again: the size, the
|
|
153
|
-
* place, maximized and full screen. Written a moment after a window stops moving and when it closes.
|
|
154
|
-
*/
|
|
163
|
+
/* Keeps where each window stood by a key the app chooses, written a moment after it stops moving and when it closes. */
|
|
155
164
|
export const createWindowState = (options: WindowStateOptions) => {
|
|
156
165
|
const delayMs = options.delayMs ?? 500;
|
|
157
166
|
const later = options.setTimeout ?? ((run, ms) => setTimeout(run, ms));
|
|
@@ -164,6 +173,7 @@ export const createWindowState = (options: WindowStateOptions) => {
|
|
|
164
173
|
cancel(timer);
|
|
165
174
|
timer = null;
|
|
166
175
|
}
|
|
176
|
+
|
|
167
177
|
options.storage.write(`${JSON.stringify({ version: FILE_VERSION, windows: Object.fromEntries(windows) }, null, 2)}\n`);
|
|
168
178
|
};
|
|
169
179
|
|
|
@@ -171,6 +181,7 @@ export const createWindowState = (options: WindowStateOptions) => {
|
|
|
171
181
|
if (timer !== null) {
|
|
172
182
|
cancel(timer);
|
|
173
183
|
}
|
|
184
|
+
|
|
174
185
|
timer = later(() => {
|
|
175
186
|
timer = null;
|
|
176
187
|
flush();
|
|
@@ -183,13 +194,13 @@ export const createWindowState = (options: WindowStateOptions) => {
|
|
|
183
194
|
fitBounds(windows.get(key) ?? fallback, options.displays(), options.defaults),
|
|
184
195
|
|
|
185
196
|
/*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* given as a function is asked at every change, for a window that comes to show something else.
|
|
197
|
+
* Call right after constructing the window, before it is shown; it is maximized or put in full screen
|
|
198
|
+
* as it was left. A key given as a function is asked at every change, for a window that shows another.
|
|
189
199
|
*/
|
|
190
200
|
track: (key: string | (() => string), window: StateWindow): void => {
|
|
191
201
|
const keyNow = typeof key === 'function' ? key : () => key;
|
|
192
202
|
const last = windows.get(keyNow());
|
|
203
|
+
|
|
193
204
|
window.once('ready-to-show', () => {
|
|
194
205
|
if (last?.fullScreen) {
|
|
195
206
|
window.setFullScreen(true);
|
|
@@ -197,25 +208,31 @@ export const createWindowState = (options: WindowStateOptions) => {
|
|
|
197
208
|
window.maximize();
|
|
198
209
|
}
|
|
199
210
|
});
|
|
211
|
+
|
|
200
212
|
const remember = (): boolean => {
|
|
201
213
|
if (window.isDestroyed()) {
|
|
202
214
|
return false;
|
|
203
215
|
}
|
|
216
|
+
|
|
204
217
|
const bounds = window.getNormalBounds();
|
|
205
218
|
windows.set(keyNow(), { ...bounds, maximized: window.isMaximized(), fullScreen: window.isFullScreen() });
|
|
219
|
+
|
|
206
220
|
return true;
|
|
207
221
|
};
|
|
222
|
+
|
|
208
223
|
const changed = (): void => {
|
|
209
224
|
if (remember()) {
|
|
210
225
|
schedule();
|
|
211
226
|
}
|
|
212
227
|
};
|
|
228
|
+
|
|
213
229
|
window.on('resize', changed);
|
|
214
230
|
window.on('move', changed);
|
|
215
231
|
window.on('maximize', changed);
|
|
216
232
|
window.on('unmaximize', changed);
|
|
217
233
|
window.on('enter-full-screen', changed);
|
|
218
234
|
window.on('leave-full-screen', changed);
|
|
235
|
+
|
|
219
236
|
window.on('close', () => {
|
|
220
237
|
remember();
|
|
221
238
|
flush();
|
package/src/windows.ts
CHANGED
|
@@ -71,19 +71,26 @@ const parseSession = (text: string | null): SessionWindow[] => {
|
|
|
71
71
|
if (text === null) {
|
|
72
72
|
return [];
|
|
73
73
|
}
|
|
74
|
+
|
|
74
75
|
try {
|
|
75
76
|
const file = JSON.parse(text) as { version?: unknown; windows?: unknown; states?: unknown };
|
|
77
|
+
|
|
76
78
|
if (file.version !== SESSION_VERSION || !Array.isArray(file.windows)) {
|
|
77
79
|
return [];
|
|
78
80
|
}
|
|
81
|
+
|
|
79
82
|
const states: unknown[] = Array.isArray(file.states) ? file.states : [];
|
|
83
|
+
|
|
80
84
|
const windows = file.windows.flatMap((key: unknown, index): SessionWindow[] => {
|
|
81
85
|
if (key !== null && (typeof key !== 'string' || key === '')) {
|
|
82
86
|
return [];
|
|
83
87
|
}
|
|
88
|
+
|
|
84
89
|
const state = states[index];
|
|
90
|
+
|
|
85
91
|
return [{ key, state: typeof state === 'string' ? state : null }];
|
|
86
92
|
});
|
|
93
|
+
|
|
87
94
|
// A key belongs to one window, so a file that lists one twice keeps the last.
|
|
88
95
|
return windows.filter(({ key }, index) => key === null || windows.findLastIndex((other) => other.key === key) === index);
|
|
89
96
|
} catch {
|
|
@@ -114,16 +121,25 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
114
121
|
let installing = false;
|
|
115
122
|
const later = options.setTimeout ?? ((run, ms) => setTimeout(run, ms));
|
|
116
123
|
|
|
117
|
-
const
|
|
124
|
+
const isAlive = (entry: Entry | undefined): entry is Entry => entry !== undefined && !entry.window.isDestroyed();
|
|
125
|
+
|
|
126
|
+
const alive = (): Entry[] => [...entries.values()].filter(isAlive);
|
|
127
|
+
|
|
128
|
+
const frontToBack = (): Entry[] =>
|
|
129
|
+
order
|
|
130
|
+
.toReversed()
|
|
131
|
+
.map((id) => entries.get(id))
|
|
132
|
+
.filter(isAlive);
|
|
118
133
|
|
|
119
134
|
const writeSession = (): void => {
|
|
120
135
|
if (quitting) {
|
|
121
136
|
return;
|
|
122
137
|
}
|
|
138
|
+
|
|
123
139
|
const shown = order.map((id) => entries.get(id));
|
|
124
140
|
const keys = shown.map((entry) => entry?.key ?? null);
|
|
125
141
|
const states = shown.map((entry) => entry?.state ?? null);
|
|
126
|
-
const file = states.some((state) => state !== null) ? {
|
|
142
|
+
const file = { version: SESSION_VERSION, windows: keys, ...(states.some((state) => state !== null) ? { states } : {}) };
|
|
127
143
|
options.session.write(`${JSON.stringify(file, null, 2)}\n`);
|
|
128
144
|
};
|
|
129
145
|
|
|
@@ -131,15 +147,7 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
131
147
|
order = [...order.filter((other) => other !== id), id];
|
|
132
148
|
};
|
|
133
149
|
|
|
134
|
-
const focused = (): BrowserWindow | null =>
|
|
135
|
-
for (let i = order.length - 1; i >= 0; i--) {
|
|
136
|
-
const entry = entries.get(order[i]!);
|
|
137
|
-
if (entry && !entry.window.isDestroyed()) {
|
|
138
|
-
return entry.window;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return null;
|
|
142
|
-
};
|
|
150
|
+
const focused = (): BrowserWindow | null => frontToBack()[0]?.window ?? null;
|
|
143
151
|
|
|
144
152
|
const holderOf = (key: string): Entry | null => alive().find((entry) => entry.key === key || entry.held.includes(key)) ?? null;
|
|
145
153
|
|
|
@@ -148,6 +156,7 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
148
156
|
if (entry.key === key) {
|
|
149
157
|
entry.key = null;
|
|
150
158
|
}
|
|
159
|
+
|
|
151
160
|
entry.held = entry.held.filter((other) => other !== key);
|
|
152
161
|
};
|
|
153
162
|
|
|
@@ -157,20 +166,26 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
157
166
|
if (window.isMinimized()) {
|
|
158
167
|
window.restore();
|
|
159
168
|
}
|
|
169
|
+
|
|
160
170
|
window.show();
|
|
161
171
|
window.focus();
|
|
162
172
|
};
|
|
163
173
|
|
|
164
174
|
const boundsFor = (key: string | null): Partial<Rectangle> & WindowSize => {
|
|
165
175
|
const stateKey = key ?? UNKEYED_STATE;
|
|
176
|
+
|
|
166
177
|
if (options.state.has(stateKey)) {
|
|
167
178
|
return options.state.bounds(stateKey);
|
|
168
179
|
}
|
|
180
|
+
|
|
169
181
|
const front = focused();
|
|
182
|
+
|
|
170
183
|
if (!front) {
|
|
171
184
|
return options.state.bounds(options.formerKey !== undefined && options.state.has(options.formerKey) ? options.formerKey : stateKey);
|
|
172
185
|
}
|
|
186
|
+
|
|
173
187
|
const beside = front.getNormalBounds();
|
|
188
|
+
|
|
174
189
|
return options.state.bounds(stateKey, { ...beside, x: beside.x + CASCADE, y: beside.y + CASCADE });
|
|
175
190
|
};
|
|
176
191
|
|
|
@@ -182,11 +197,13 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
182
197
|
entries.set(id, entry);
|
|
183
198
|
toFront(id);
|
|
184
199
|
options.state.track(() => entry.key ?? UNKEYED_STATE, window);
|
|
200
|
+
|
|
185
201
|
// The order only decides which window comes back in front, so it is written with the next change rather than at every focus.
|
|
186
202
|
window.on('focus', () => {
|
|
187
203
|
toFront(id);
|
|
188
204
|
options.onFront?.(window);
|
|
189
205
|
});
|
|
206
|
+
|
|
190
207
|
window.on('close', (event) => {
|
|
191
208
|
if (quitsWithLastWindow && alive().length === 1) {
|
|
192
209
|
markQuit();
|
|
@@ -199,21 +216,27 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
199
216
|
}
|
|
200
217
|
}
|
|
201
218
|
});
|
|
219
|
+
|
|
202
220
|
window.on('closed', () => {
|
|
203
221
|
entries.delete(id);
|
|
204
222
|
order = order.filter((other) => other !== id);
|
|
223
|
+
|
|
205
224
|
for (const [guest, owner] of guestOwners) {
|
|
206
225
|
if (owner === id) {
|
|
207
226
|
guestOwners.delete(guest);
|
|
208
227
|
}
|
|
209
228
|
}
|
|
229
|
+
|
|
210
230
|
writeSession();
|
|
211
231
|
});
|
|
232
|
+
|
|
212
233
|
window.webContents.on('did-attach-webview', (_event, guest) => {
|
|
213
234
|
guestOwners.set(guest.id, id);
|
|
214
235
|
guest.once('destroyed', () => guestOwners.delete(guest.id));
|
|
215
236
|
});
|
|
237
|
+
|
|
216
238
|
writeSession();
|
|
239
|
+
|
|
217
240
|
return window;
|
|
218
241
|
};
|
|
219
242
|
|
|
@@ -225,10 +248,13 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
225
248
|
const restore = (): void => {
|
|
226
249
|
const windows = parseSession(options.session.read());
|
|
227
250
|
started = true;
|
|
251
|
+
|
|
228
252
|
if (windows.length === 0) {
|
|
229
253
|
create(null, 'first');
|
|
254
|
+
|
|
230
255
|
return;
|
|
231
256
|
}
|
|
257
|
+
|
|
232
258
|
for (const { key, state } of windows) {
|
|
233
259
|
create(key, 'session', state);
|
|
234
260
|
}
|
|
@@ -247,6 +273,7 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
247
273
|
if (quitting && started && alive().length === 0) {
|
|
248
274
|
restore();
|
|
249
275
|
}
|
|
276
|
+
|
|
250
277
|
resume();
|
|
251
278
|
};
|
|
252
279
|
|
|
@@ -254,10 +281,13 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
254
281
|
/* Raises the window that has the key, or opens one for it, at `at` when given. Null always opens a new window. */
|
|
255
282
|
open: (key: string | null, at?: WindowPoint): BrowserWindow => {
|
|
256
283
|
const holder = key === null ? null : holderOf(key);
|
|
284
|
+
|
|
257
285
|
if (holder) {
|
|
258
286
|
raise(holder.window);
|
|
287
|
+
|
|
259
288
|
return holder.window;
|
|
260
289
|
}
|
|
290
|
+
|
|
261
291
|
return create(key, 'opened', null, at);
|
|
262
292
|
},
|
|
263
293
|
|
|
@@ -270,16 +300,22 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
270
300
|
*/
|
|
271
301
|
claim: (window: BrowserWindow, key: string | null): boolean => {
|
|
272
302
|
const entry = entries.get(window.id);
|
|
303
|
+
|
|
273
304
|
if (!entry) {
|
|
274
305
|
return false;
|
|
275
306
|
}
|
|
307
|
+
|
|
276
308
|
const holder = key === null ? null : holderOf(key);
|
|
309
|
+
|
|
277
310
|
if (holder && holder !== entry) {
|
|
278
311
|
raise(holder.window);
|
|
312
|
+
|
|
279
313
|
return false;
|
|
280
314
|
}
|
|
315
|
+
|
|
281
316
|
entry.key = key;
|
|
282
317
|
writeSession();
|
|
318
|
+
|
|
283
319
|
return true;
|
|
284
320
|
},
|
|
285
321
|
|
|
@@ -295,11 +331,14 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
295
331
|
*/
|
|
296
332
|
move: (window: BrowserWindow, at?: WindowPoint): BrowserWindow | null => {
|
|
297
333
|
const entry = entries.get(window.id);
|
|
334
|
+
|
|
298
335
|
if (!entry || entry.key === null) {
|
|
299
336
|
return null;
|
|
300
337
|
}
|
|
338
|
+
|
|
301
339
|
const key = entry.key;
|
|
302
340
|
release(entry, key);
|
|
341
|
+
|
|
303
342
|
return create(key, 'opened', null, at);
|
|
304
343
|
},
|
|
305
344
|
|
|
@@ -309,13 +348,17 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
309
348
|
*/
|
|
310
349
|
hold: (window: BrowserWindow, keys: readonly string[]): string[] => {
|
|
311
350
|
const entry = entries.get(window.id);
|
|
351
|
+
|
|
312
352
|
if (!entry) {
|
|
313
353
|
return [];
|
|
314
354
|
}
|
|
355
|
+
|
|
315
356
|
entry.held = [...new Set(keys)].filter((key) => {
|
|
316
357
|
const holder = holderOf(key);
|
|
358
|
+
|
|
317
359
|
return holder === null || holder === entry;
|
|
318
360
|
});
|
|
361
|
+
|
|
319
362
|
return entry.held;
|
|
320
363
|
},
|
|
321
364
|
|
|
@@ -325,11 +368,14 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
325
368
|
*/
|
|
326
369
|
detach: (window: BrowserWindow, key: string, at?: WindowPoint): BrowserWindow | null => {
|
|
327
370
|
const entry = entries.get(window.id);
|
|
371
|
+
|
|
328
372
|
if (!entry || (entry.key !== key && !entry.held.includes(key))) {
|
|
329
373
|
return null;
|
|
330
374
|
}
|
|
375
|
+
|
|
331
376
|
release(entry, key);
|
|
332
377
|
writeSession();
|
|
378
|
+
|
|
333
379
|
return create(key, 'opened', null, at);
|
|
334
380
|
},
|
|
335
381
|
|
|
@@ -339,17 +385,23 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
339
385
|
*/
|
|
340
386
|
transfer: (key: string, window: BrowserWindow): boolean => {
|
|
341
387
|
const target = entries.get(window.id);
|
|
388
|
+
|
|
342
389
|
if (!target) {
|
|
343
390
|
return false;
|
|
344
391
|
}
|
|
392
|
+
|
|
345
393
|
const holder = holderOf(key);
|
|
394
|
+
|
|
346
395
|
if (holder !== null && holder !== target) {
|
|
347
396
|
release(holder, key);
|
|
348
397
|
}
|
|
398
|
+
|
|
349
399
|
if (!target.held.includes(key)) {
|
|
350
400
|
target.held = [...target.held, key];
|
|
351
401
|
}
|
|
402
|
+
|
|
352
403
|
writeSession();
|
|
404
|
+
|
|
353
405
|
return true;
|
|
354
406
|
},
|
|
355
407
|
|
|
@@ -357,19 +409,16 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
357
409
|
holderOf: (key: string): BrowserWindow | null => holderOf(key)?.window ?? null,
|
|
358
410
|
|
|
359
411
|
/* The app window under a point on the screen, the one last in front first, leaving out `except`. */
|
|
360
|
-
windowAt: (point: WindowPoint, except?: BrowserWindow): BrowserWindow | null =>
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
continue;
|
|
365
|
-
}
|
|
366
|
-
const bounds = entry.window.getBounds();
|
|
367
|
-
if (point.x >= bounds.x && point.x < bounds.x + bounds.width && point.y >= bounds.y && point.y < bounds.y + bounds.height) {
|
|
368
|
-
return entry.window;
|
|
412
|
+
windowAt: (point: WindowPoint, except?: BrowserWindow): BrowserWindow | null =>
|
|
413
|
+
frontToBack().find(({ window }) => {
|
|
414
|
+
if (window === except || window.isMinimized() || !window.isVisible()) {
|
|
415
|
+
return false;
|
|
369
416
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
417
|
+
|
|
418
|
+
const bounds = window.getBounds();
|
|
419
|
+
|
|
420
|
+
return point.x >= bounds.x && point.x < bounds.x + bounds.width && point.y >= bounds.y && point.y < bounds.y + bounds.height;
|
|
421
|
+
})?.window ?? null,
|
|
373
422
|
|
|
374
423
|
/*
|
|
375
424
|
* What a window's page keeps of itself across a quit, opaque to the shell, such as the tabs it
|
|
@@ -377,9 +426,11 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
377
426
|
*/
|
|
378
427
|
setState: (window: BrowserWindow, state: string | null): void => {
|
|
379
428
|
const entry = entries.get(window.id);
|
|
429
|
+
|
|
380
430
|
if (!entry || entry.state === state) {
|
|
381
431
|
return;
|
|
382
432
|
}
|
|
433
|
+
|
|
383
434
|
entry.state = state;
|
|
384
435
|
writeSession();
|
|
385
436
|
},
|
|
@@ -387,9 +438,11 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
387
438
|
/* The window a page or a `<webview>` inside it belongs to, such as whose menu a guest opens. Not a check of who may speak for the app: that is `fromPage`. */
|
|
388
439
|
fromContents: (contents: WebContents): BrowserWindow | null => {
|
|
389
440
|
const guestOwner = guestOwners.get(contents.id);
|
|
441
|
+
|
|
390
442
|
if (guestOwner !== undefined) {
|
|
391
443
|
return entries.get(guestOwner)?.window ?? null;
|
|
392
444
|
}
|
|
445
|
+
|
|
393
446
|
return pageWindow(contents);
|
|
394
447
|
},
|
|
395
448
|
|
|
@@ -429,54 +482,65 @@ export const createWindows = (options: WindowsOptions) => {
|
|
|
429
482
|
},
|
|
430
483
|
|
|
431
484
|
/*
|
|
432
|
-
*
|
|
433
|
-
* the
|
|
434
|
-
*
|
|
435
|
-
* quit, and off macOS the app quits with its last window while that window stays in the session.
|
|
436
|
-
* With `confirmQuit`, a quit waits for its answer. Call it before `app.whenReady()`, and
|
|
437
|
-
* `restore()` once ready.
|
|
485
|
+
* Follows the app's events: a second instance raises the window in front, the dock icon with no
|
|
486
|
+
* window reopens the last session, a quit keeps its windows in the session, and off macOS the app
|
|
487
|
+
* quits with its last window. Call it before `app.whenReady()`, and `restore()` once ready.
|
|
438
488
|
*/
|
|
439
489
|
attach: (app: Pick<App, 'on' | 'quit'>, attachOptions: AttachOptions = {}): void => {
|
|
440
490
|
const { confirmQuit } = attachOptions;
|
|
441
491
|
const platform = options.platform ?? process.platform;
|
|
442
492
|
quitsWithLastWindow = platform !== 'darwin';
|
|
493
|
+
|
|
443
494
|
app.on('second-instance', () => {
|
|
444
495
|
const window = focused();
|
|
496
|
+
|
|
445
497
|
if (window) {
|
|
446
498
|
raise(window);
|
|
447
499
|
} else if (started) {
|
|
448
500
|
restore();
|
|
449
501
|
}
|
|
450
502
|
});
|
|
503
|
+
|
|
451
504
|
app.on('activate', () => {
|
|
452
505
|
if (started && alive().length === 0) {
|
|
453
506
|
restore();
|
|
454
507
|
}
|
|
455
508
|
});
|
|
509
|
+
|
|
456
510
|
app.on('before-quit', (event) => {
|
|
457
511
|
if (confirmQuit === undefined || quitConfirmed) {
|
|
458
512
|
quitEvent = event;
|
|
513
|
+
|
|
459
514
|
return;
|
|
460
515
|
}
|
|
516
|
+
|
|
461
517
|
event.preventDefault();
|
|
518
|
+
|
|
462
519
|
if (confirming) {
|
|
463
520
|
return;
|
|
464
521
|
}
|
|
522
|
+
|
|
465
523
|
confirming = true;
|
|
524
|
+
|
|
466
525
|
void new Promise<boolean>((resolve) => resolve(confirmQuit({ update: installing })))
|
|
467
526
|
.catch(() => true)
|
|
468
527
|
.then((go) => {
|
|
469
528
|
confirming = false;
|
|
529
|
+
|
|
470
530
|
if (!go) {
|
|
471
531
|
stay();
|
|
532
|
+
|
|
472
533
|
return;
|
|
473
534
|
}
|
|
535
|
+
|
|
474
536
|
quitConfirmed = true;
|
|
475
537
|
// A quit asked from inside this event would be undone by the one still running, so it waits for a task of its own.
|
|
476
538
|
later(() => app.quit(), 0);
|
|
477
539
|
});
|
|
478
540
|
});
|
|
541
|
+
|
|
479
542
|
app.on('will-quit', () => options.state.flush());
|
|
543
|
+
|
|
480
544
|
app.on('window-all-closed', () => {
|
|
481
545
|
if (quitsWithLastWindow) {
|
|
482
546
|
app.quit();
|