@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.
@@ -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,
@@ -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 that stands until the page sends its own, again after a reload or a crash, and while no
43
- * window is open, so Quit is always there. The stock View menu without reload and the zoom roles: their accelerators are taken
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`), which runs it the way its own palette does. One fired by
82
- * a key the page already had is dropped: its own listeners answered it or let it pass on purpose. Only a
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, so an item picked through accessibility
6
- * or with the keyboard inside the menu reads as fired by its key as well, and a key sent to the app
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 can paint: the ground of every window, so
3
- * a reload or a resize never flashes the other theme, the native controls off macOS, and the
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, and a build already waiting to be installed
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 owns the preference and sends it before the first check, so nothing downloads
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 }));
@@ -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
- * The bounds to open a window with. Saved bounds stand as long as a display still shows enough of them
10
- * to grab, shrunk to that display's work area; a display that was unplugged or rearranged since leaves
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
- * Follows a window from now on and, once it is ready to show, maximizes it or puts it in full
131
- * screen as it was left. Call it right after constructing the window, before it is shown. A key
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 alive = () => [...entries.values()].filter((entry) => !entry.window.isDestroyed());
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) ? { version: SESSION_VERSION, windows: keys, states } : { version: SESSION_VERSION, windows: keys };
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
- for (let i = order.length - 1; i >= 0; i--) {
280
- const entry = entries.get(order[i]);
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
- return null;
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
- * What every app with this set of windows does with its app's events: a second instance raises
343
- * the window in front, a click on the dock icon with no window open opens the last session's
344
- * again, a quit that goes ahead keeps its windows in the session, pending bounds are written at
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.39.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.2",
54
- "@types/node": "^24.13.6",
55
- "electron": "^44.9.0",
56
- "electron-updater": "^6.8.9",
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,
@@ -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. */
@@ -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 that stands until the page sends its own, again after a reload or a crash, and while no
70
- * window is open, so Quit is always there. The stock View menu without reload and the zoom roles: their accelerators are taken
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`), which runs it the way its own palette does. One fired by
126
- * a key the page already had is dropped: its own listeners answered it or let it pass on purpose. Only a
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, so an item picked through accessibility
14
- * or with the keyboard inside the menu reads as fired by its key as well, and a key sent to the app
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 can paint: the ground of every window, so
24
- * a reload or a resize never flashes the other theme, the native controls off macOS, and the
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, and a build already waiting to be installed
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 owns the preference and sends it before the first check, so nothing downloads
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
 
@@ -30,21 +30,22 @@ const overlap = (a: Rectangle, b: Rectangle): WindowSize => ({
30
30
  });
31
31
 
32
32
  /*
33
- * The bounds to open a window with. Saved bounds stand as long as a display still shows enough of them
34
- * to grab, shrunk to that display's work area; a display that was unplugged or rearranged since leaves
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
- * Follows a window from now on and, once it is ready to show, maximizes it or puts it in full
187
- * screen as it was left. Call it right after constructing the window, before it is shown. A key
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 alive = (): Entry[] => [...entries.values()].filter((entry) => !entry.window.isDestroyed());
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) ? { version: SESSION_VERSION, windows: keys, states } : { version: SESSION_VERSION, windows: keys };
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
- for (let i = order.length - 1; i >= 0; i--) {
362
- const entry = entries.get(order[i]!);
363
- if (!entry || entry.window.isDestroyed() || entry.window === except || entry.window.isMinimized() || !entry.window.isVisible()) {
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
- return null;
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
- * What every app with this set of windows does with its app's events: a second instance raises
433
- * the window in front, a click on the dock icon with no window open opens the last session's
434
- * again, a quit that goes ahead keeps its windows in the session, pending bounds are written at
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();