@adecore/shell 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ The package registers no IPC handler and listens to no app-wide Electron event.
24
24
  | Import | What it holds |
25
25
  |---|---|
26
26
  | `@adecore/shell` | For the main process: `createWindows`, `createWindowState`, `createUpdater`, `createTheme`, `menuTemplateOf`, `staticMenuTemplate`, `createMenuCommands`, `createPageKeys`, `createAppScheme` and the web guards |
27
- | `@adecore/shell/bridge` | The shapes that cross IPC: `MenuSpec`, `MenuNode`, `MENU_ROLES`, `UpdateState`, `ThemeState` and `compareVersions`. It imports nothing from Electron, so a preload and the page read it too. |
27
+ | `@adecore/shell/bridge` | The shapes that cross IPC: `MenuSpec`, `MenuNode`, `MENU_ROLES`, `MENU_BAR_ROLES`, `UpdateState`, `ThemeState` and `compareVersions`. It imports nothing from Electron, so a preload and the page read it too. |
28
28
 
29
29
  ## Documentation
30
30
 
@@ -1,4 +1,4 @@
1
- export { MENU_ROLES, type MenuNode, type MenuRole, type MenuSpec } from './menu.ts';
1
+ export { MENU_BAR_ROLES, MENU_ROLES, type MenuBarRole, type MenuNode, type MenuRole, type MenuSpec } from './menu.ts';
2
2
  export type { ThemeState } from './theme.ts';
3
- export type { UpdateState } from './update.ts';
3
+ export type { UpdateErrorCode, UpdateState } from './update.ts';
4
4
  export { compareVersions, isVersion } from './versions.ts';
@@ -2,5 +2,5 @@
2
2
  * The shapes that cross IPC between the shell and the page it hosts. Nothing here imports Electron, so
3
3
  * a preload and a page read them as well as the main process.
4
4
  */
5
- export { MENU_ROLES } from './menu.js';
5
+ export { MENU_BAR_ROLES, MENU_ROLES } from './menu.js';
6
6
  export { compareVersions, isVersion } from './versions.js';
@@ -1,5 +1,7 @@
1
1
  export declare const MENU_ROLES: readonly ['undo', 'redo', 'cut', 'copy', 'paste', 'selectAll', 'services', 'hide', 'hideOthers', 'unhide', 'minimize', 'zoom', 'front', 'close', 'quit', 'togglefullscreen'];
2
2
  export type MenuRole = (typeof MENU_ROLES)[number];
3
+ export declare const MENU_BAR_ROLES: readonly ['window', 'help'];
4
+ export type MenuBarRole = (typeof MENU_BAR_ROLES)[number];
3
5
  export type MenuNode<Action extends string = string> = {
4
6
  kind: 'command';
5
7
  id: string;
@@ -30,6 +32,7 @@ export interface MenuSpec<Action extends string = string> {
30
32
  menus: {
31
33
  id: string;
32
34
  label: string;
35
+ role?: MenuBarRole;
33
36
  items: MenuNode<Action>[];
34
37
  }[];
35
38
  }
@@ -23,3 +23,6 @@ export const MENU_ROLES = [
23
23
  'quit',
24
24
  'togglefullscreen'
25
25
  ];
26
+ /* The roles a whole menu in the bar can take. On macOS `window` is where the system lists the open
27
+ windows and `help` gets the system's search field. */
28
+ export const MENU_BAR_ROLES = ['window', 'help'];
@@ -1,7 +1,9 @@
1
+ export type UpdateErrorCode = 'no-feed' | 'failed';
1
2
  export interface UpdateState {
2
3
  status: 'unsupported' | 'idle' | 'checking' | 'current' | 'available' | 'downloading' | 'ready' | 'error';
3
4
  currentVersion: string;
4
5
  version?: string;
5
6
  percent?: number;
6
7
  error?: string | null;
8
+ errorCode?: UpdateErrorCode | null;
7
9
  }
package/dist/index.d.ts CHANGED
@@ -13,4 +13,4 @@ export type { SavedWindow, StateWindow, WindowDisplay, WindowSize, WindowState,
13
13
  export { createTheme } from './theme.ts';
14
14
  export type { Theme, ThemeOptions, TitleBarOverlay } from './theme.ts';
15
15
  export { CASCADE, createWindows, UNKEYED_STATE } from './windows.ts';
16
- export type { WindowOrigin, Windows, WindowsOptions } from './windows.ts';
16
+ export type { AttachOptions, QuitRequest, WindowOrigin, WindowPoint, Windows, WindowsOptions } from './windows.ts';
package/dist/menu.d.ts CHANGED
@@ -13,6 +13,7 @@ export interface StaticMenuOptions {
13
13
  toggleDevTools: () => void;
14
14
  appItems?: MenuItemConstructorOptions[];
15
15
  quitItems?: MenuItemConstructorOptions[];
16
+ fileItems?: MenuItemConstructorOptions[];
16
17
  platform?: NodeJS.Platform;
17
18
  }
18
19
  export declare const staticMenuTemplate: (options: StaticMenuOptions) => MenuItemConstructorOptions[];
package/dist/menu.js CHANGED
@@ -1,4 +1,4 @@
1
- import { MENU_ROLES } from './bridge/menu.js';
1
+ import { MENU_BAR_ROLES, MENU_ROLES } from './bridge/menu.js';
2
2
  /* Named instead of the role: that one follows the focused web contents, which is a guest page as soon as one has the keyboard. */
3
3
  export const devToolsAccelerator = (platform = process.platform) => (platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I');
4
4
  /* The native menu of a spec the page sent, or null for a spec that is no menu at all. */
@@ -31,22 +31,28 @@ export const menuTemplateOf = (spec, options) => {
31
31
  }
32
32
  };
33
33
  const itemsOf = (nodes) => nodes.map(itemOf).filter((item) => item !== null);
34
- return spec.menus.map((menu) => ({ label: menu.label, submenu: itemsOf(menu.items) }));
34
+ return spec.menus.map((menu) => ({
35
+ label: menu.label,
36
+ ...(menu.role !== undefined && MENU_BAR_ROLES.includes(menu.role) ? { role: menu.role } : {}),
37
+ submenu: itemsOf(menu.items)
38
+ }));
35
39
  };
40
+ const withSeparator = (items) => (items.length > 0 ? [...items, { type: 'separator' }] : []);
36
41
  /*
37
- * The menu that stands until the page sends its own, and again after a reload or a crash, so Quit is
38
- * always there. The stock View menu without reload and the zoom roles: their accelerators are taken
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
39
44
  * before the page sees them, and a reload would drop the page's state without asking.
40
45
  */
41
46
  export const staticMenuTemplate = (options) => {
42
47
  const platform = options.platform ?? process.platform;
43
48
  const appItems = options.appItems ?? [];
44
49
  const quitItems = options.quitItems ?? [];
50
+ const fileItems = options.fileItems ?? [];
45
51
  const appMenu = platform === 'darwin'
46
52
  ? {
47
53
  label: options.appName,
48
54
  submenu: [
49
- ...(appItems.length > 0 ? [...appItems, { type: 'separator' }] : []),
55
+ ...withSeparator(appItems),
50
56
  { role: 'services' },
51
57
  { type: 'separator' },
52
58
  { role: 'hide' },
@@ -57,9 +63,10 @@ export const staticMenuTemplate = (options) => {
57
63
  { role: 'quit' }
58
64
  ]
59
65
  }
60
- : quitItems.length > 0
61
- ? { label: 'File', submenu: [...quitItems, { type: 'separator' }, { role: 'quit' }] }
66
+ : fileItems.length > 0 || quitItems.length > 0
67
+ ? { label: 'File', submenu: [...withSeparator(fileItems), ...withSeparator(quitItems), { role: 'quit' }] }
62
68
  : { role: 'fileMenu' };
69
+ const fileMenu = platform === 'darwin' && fileItems.length > 0 ? [{ label: 'File', submenu: fileItems }] : [];
63
70
  const viewMenu = {
64
71
  label: 'View',
65
72
  submenu: [
@@ -68,7 +75,7 @@ export const staticMenuTemplate = (options) => {
68
75
  { role: 'togglefullscreen' }
69
76
  ]
70
77
  };
71
- return [appMenu, { role: 'editMenu' }, viewMenu, { role: 'windowMenu' }];
78
+ return [appMenu, ...fileMenu, { role: 'editMenu' }, viewMenu, { role: 'windowMenu' }];
72
79
  };
73
80
  /*
74
81
  * Runs a menu command in the page (`menu:run`), which runs it the way its own palette does. One fired by
package/dist/updater.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import type { AppUpdater } from 'electron-updater';
2
- import type { UpdateState } from './bridge/update.ts';
2
+ import type { UpdateErrorCode, UpdateState } from './bridge/update.ts';
3
3
  export declare const UPDATE_INTERVAL_MS: number;
4
- export declare const describeUpdateError: (message: string) => string;
4
+ export declare const describeUpdateError: (message: string) => {
5
+ code: UpdateErrorCode;
6
+ detail: string | null;
7
+ };
5
8
  export interface UpdaterOptions {
6
9
  currentVersion: string;
7
10
  packaged: boolean;
package/dist/updater.js CHANGED
@@ -2,18 +2,18 @@
2
2
  export const UPDATE_INTERVAL_MS = 60 * 60 * 1000;
3
3
  /*
4
4
  * electron-updater puts the whole HTTP exchange in the message of a failed check: every response
5
- * header, the session cookie among them. Only the first line travels, and a 404 on the feed gets the
6
- * sentence that says what went wrong.
5
+ * header, the session cookie among them. Only the first line travels. The words are the page's, in
6
+ * the person's language, so the shell sends a code beside that line and no sentence of its own.
7
7
  */
8
8
  export const describeUpdateError = (message) => {
9
9
  const first = message.split('\n')[0]?.trim();
10
10
  if (!first) {
11
- return 'No reason given.';
11
+ return { code: 'failed', detail: null };
12
12
  }
13
- if (first.startsWith('404')) {
14
- return 'No release feed found. There is no published release yet, or the repository is private.';
15
- }
16
- return first.length > 200 ? `${first.slice(0, 200)}…` : first;
13
+ return {
14
+ code: first.startsWith('404') ? 'no-feed' : 'failed',
15
+ detail: first.length > 200 ? `${first.slice(0, 200)}…` : first
16
+ };
17
17
  };
18
18
  /*
19
19
  * The updater as a state machine the page watches, not a dialog that interrupts. In a checkout the
@@ -62,14 +62,15 @@ export const createUpdater = (options) => {
62
62
  // The page owns the preference and sends it before the first check, so nothing downloads
63
63
  // behind the back of someone who turned it off.
64
64
  loaded.autoDownload = false;
65
- loaded.on('checking-for-update', () => setState({ status: 'checking', error: null }));
66
- loaded.on('update-available', (info) => setState({ status: 'available', version: info.version, error: null }));
67
- loaded.on('update-not-available', () => setState({ status: 'current', version: undefined, error: null }));
65
+ loaded.on('checking-for-update', () => setState({ status: 'checking', error: null, errorCode: null }));
66
+ loaded.on('update-available', (info) => setState({ status: 'available', version: info.version, error: null, errorCode: null }));
67
+ loaded.on('update-not-available', () => setState({ status: 'current', version: undefined, error: null, errorCode: null }));
68
68
  loaded.on('download-progress', (progress) => setState({ status: 'downloading', percent: progress.percent }));
69
69
  loaded.on('update-downloaded', (info) => setState({ status: 'ready', version: info.version, percent: 100 }));
70
70
  loaded.on('error', (e) => {
71
71
  installFailed();
72
- setState({ status: 'error', error: describeUpdateError(e.message) });
72
+ const { code, detail } = describeUpdateError(e.message);
73
+ setState({ status: 'error', error: detail, errorCode: code });
73
74
  });
74
75
  updater = loaded;
75
76
  setState({ status: 'idle' });
package/dist/windows.d.ts CHANGED
@@ -6,24 +6,42 @@ export type WindowOrigin = 'session' | 'first' | 'opened';
6
6
  export interface WindowsOptions {
7
7
  state: WindowState;
8
8
  session: WindowStateStorage;
9
- create: (key: string | null, bounds: Partial<Rectangle> & WindowSize, origin: WindowOrigin) => BrowserWindow;
9
+ create: (key: string | null, bounds: Partial<Rectangle> & WindowSize, origin: WindowOrigin, state: string | null) => BrowserWindow;
10
10
  onFront?: (window: BrowserWindow) => void;
11
11
  formerKey?: string;
12
12
  platform?: NodeJS.Platform;
13
+ setTimeout?: (run: () => void, ms: number) => unknown;
14
+ }
15
+ export interface QuitRequest {
16
+ update: boolean;
17
+ }
18
+ export interface AttachOptions {
19
+ confirmQuit?: (request: QuitRequest) => boolean | Promise<boolean>;
20
+ }
21
+ export interface WindowPoint {
22
+ x: number;
23
+ y: number;
13
24
  }
14
25
  export declare const createWindows: (options: WindowsOptions) => {
15
- open: (key: string | null) => BrowserWindow;
26
+ open: (key: string | null, at?: WindowPoint) => BrowserWindow;
16
27
  restore: () => void;
17
28
  claim: (window: BrowserWindow, key: string | null) => boolean;
18
29
  fromPage: (contents: WebContents) => BrowserWindow | null;
19
- move: (window: BrowserWindow) => BrowserWindow | null;
30
+ move: (window: BrowserWindow, at?: WindowPoint) => BrowserWindow | null;
31
+ hold: (window: BrowserWindow, keys: readonly string[]) => string[];
32
+ detach: (window: BrowserWindow, key: string, at?: WindowPoint) => BrowserWindow | null;
33
+ transfer: (key: string, window: BrowserWindow) => boolean;
34
+ holderOf: (key: string) => BrowserWindow | null;
35
+ windowAt: (point: WindowPoint, except?: BrowserWindow) => BrowserWindow | null;
36
+ setState: (window: BrowserWindow, state: string | null) => void;
20
37
  fromContents: (contents: WebContents) => BrowserWindow | null;
21
38
  focused: () => BrowserWindow | null;
22
39
  keyOf: (window: BrowserWindow) => string | null;
23
40
  all: () => BrowserWindow[];
24
41
  send: (channel: string, ...args: unknown[]) => void;
25
42
  quit: () => void;
26
- attach: (app: Pick<App, 'on' | 'quit'>) => void;
43
+ quitForUpdate: (going: boolean) => void;
44
+ attach: (app: Pick<App, 'on' | 'quit'>, attachOptions?: AttachOptions) => void;
27
45
  resume: () => void;
28
46
  };
29
47
  export type Windows = ReturnType<typeof createWindows>;
package/dist/windows.js CHANGED
@@ -3,6 +3,10 @@ export const UNKEYED_STATE = 'start';
3
3
  /* How far a new window sits from the one in front, when its key has no bounds of its own. */
4
4
  export const CASCADE = 24;
5
5
  const SESSION_VERSION = 1;
6
+ /*
7
+ * The states sit in a list of their own beside the keys, at the same places, so a shell from before
8
+ * them still reads the keys and opens the windows without what their pages kept.
9
+ */
6
10
  const parseSession = (text) => {
7
11
  if (text === null) {
8
12
  return [];
@@ -12,9 +16,16 @@ const parseSession = (text) => {
12
16
  if (file.version !== SESSION_VERSION || !Array.isArray(file.windows)) {
13
17
  return [];
14
18
  }
15
- const keys = file.windows.filter((key) => key === null || (typeof key === 'string' && key !== ''));
19
+ const states = Array.isArray(file.states) ? file.states : [];
20
+ const windows = file.windows.flatMap((key, index) => {
21
+ if (key !== null && (typeof key !== 'string' || key === '')) {
22
+ return [];
23
+ }
24
+ const state = states[index];
25
+ return [{ key, state: typeof state === 'string' ? state : null }];
26
+ });
16
27
  // A key belongs to one window, so a file that lists one twice keeps the last.
17
- return keys.filter((key, index) => key === null || keys.lastIndexOf(key) === index);
28
+ return windows.filter(({ key }, index) => key === null || windows.findLastIndex((other) => other.key === key) === index);
18
29
  }
19
30
  catch {
20
31
  return [];
@@ -36,13 +47,22 @@ export const createWindows = (options) => {
36
47
  let started = false;
37
48
  // Off macOS the app quits with its last window, after that window already left the session.
38
49
  let quitsWithLastWindow = false;
50
+ // The app's last `before-quit`. Its windows stay in the session as they close, unless a listener after ours kept the app open.
51
+ let quitEvent = null;
52
+ let quitConfirmed = false;
53
+ let confirming = false;
54
+ let installing = false;
55
+ const later = options.setTimeout ?? ((run, ms) => setTimeout(run, ms));
39
56
  const alive = () => [...entries.values()].filter((entry) => !entry.window.isDestroyed());
40
57
  const writeSession = () => {
41
58
  if (quitting) {
42
59
  return;
43
60
  }
44
- const keys = order.map((id) => entries.get(id)?.key ?? null);
45
- options.session.write(`${JSON.stringify({ version: SESSION_VERSION, windows: keys }, null, 2)}\n`);
61
+ const shown = order.map((id) => entries.get(id));
62
+ const keys = shown.map((entry) => entry?.key ?? null);
63
+ 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 };
65
+ options.session.write(`${JSON.stringify(file, null, 2)}\n`);
46
66
  };
47
67
  const toFront = (id) => {
48
68
  order = [...order.filter((other) => other !== id), id];
@@ -56,7 +76,14 @@ export const createWindows = (options) => {
56
76
  }
57
77
  return null;
58
78
  };
59
- const holderOf = (key) => alive().find((entry) => entry.key === key) ?? null;
79
+ const holderOf = (key) => alive().find((entry) => entry.key === key || entry.held.includes(key)) ?? null;
80
+ /* Takes a key out of the window that has it, shown or kept. */
81
+ const release = (entry, key) => {
82
+ if (entry.key === key) {
83
+ entry.key = null;
84
+ }
85
+ entry.held = entry.held.filter((other) => other !== key);
86
+ };
60
87
  const pageWindow = (contents) => alive().find((entry) => entry.window.webContents === contents)?.window ?? null;
61
88
  const raise = (window) => {
62
89
  if (window.isMinimized()) {
@@ -77,9 +104,10 @@ export const createWindows = (options) => {
77
104
  const beside = front.getNormalBounds();
78
105
  return options.state.bounds(stateKey, { ...beside, x: beside.x + CASCADE, y: beside.y + CASCADE });
79
106
  };
80
- const create = (key, origin) => {
81
- const window = options.create(key, boundsFor(key), origin);
82
- const entry = { window, key };
107
+ const create = (key, origin, state = null, at) => {
108
+ const bounds = boundsFor(key);
109
+ const window = options.create(key, at === undefined ? bounds : { ...bounds, x: Math.round(at.x), y: Math.round(at.y) }, origin, state);
110
+ const entry = { window, key, state, held: [] };
83
111
  const id = window.id;
84
112
  entries.set(id, entry);
85
113
  toFront(id);
@@ -89,10 +117,19 @@ export const createWindows = (options) => {
89
117
  toFront(id);
90
118
  options.onFront?.(window);
91
119
  });
92
- window.on('close', () => {
120
+ window.on('close', (event) => {
93
121
  if (quitsWithLastWindow && alive().length === 1) {
94
122
  markQuit();
95
123
  }
124
+ else if (quitEvent !== null && !quitEvent.defaultPrevented) {
125
+ if (event.defaultPrevented) {
126
+ // A window that stays open stops Electron's quit, so a window that closes later is a person's doing.
127
+ quitEvent = null;
128
+ }
129
+ else {
130
+ markQuit();
131
+ }
132
+ }
96
133
  });
97
134
  window.on('closed', () => {
98
135
  entries.delete(id);
@@ -116,31 +153,45 @@ export const createWindows = (options) => {
116
153
  quitting = true;
117
154
  };
118
155
  const restore = () => {
119
- const keys = parseSession(options.session.read());
156
+ const windows = parseSession(options.session.read());
120
157
  started = true;
121
- if (keys.length === 0) {
158
+ if (windows.length === 0) {
122
159
  create(null, 'first');
123
160
  return;
124
161
  }
125
- for (const key of keys) {
126
- create(key, 'session');
162
+ for (const { key, state } of windows) {
163
+ create(key, 'session', state);
164
+ }
165
+ };
166
+ const resume = () => {
167
+ quitting = false;
168
+ quitEvent = null;
169
+ quitConfirmed = false;
170
+ installing = false;
171
+ writeSession();
172
+ };
173
+ /* The quit does not happen. Windows it already closed, the last one off macOS or all of them for an install, come back as they were. */
174
+ const stay = () => {
175
+ if (quitting && started && alive().length === 0) {
176
+ restore();
127
177
  }
178
+ resume();
128
179
  };
129
180
  return {
130
- /* Raises the window that has the key, or opens one for it. Null always opens a new window. */
131
- open: (key) => {
181
+ /* Raises the window that has the key, or opens one for it, at `at` when given. Null always opens a new window. */
182
+ open: (key, at) => {
132
183
  const holder = key === null ? null : holderOf(key);
133
184
  if (holder) {
134
185
  raise(holder.window);
135
186
  return holder.window;
136
187
  }
137
- return create(key, 'opened');
188
+ return create(key, 'opened', null, at);
138
189
  },
139
190
  /* Opens the windows that were open when the app last quit, or one without a key. */
140
191
  restore,
141
192
  /*
142
193
  * Makes a window the one that shows a key, as when its page opens a project. False when another
143
- * window already has it; that window comes to the front instead. Null lets the key go.
194
+ * window already shows or keeps it; that window comes to the front instead. Null lets the key go.
144
195
  */
145
196
  claim: (window, key) => {
146
197
  const entry = entries.get(window.id);
@@ -165,14 +216,89 @@ export const createWindows = (options) => {
165
216
  * Moves what a window shows into a new window, which comes to the front, and leaves the old one
166
217
  * without a key. The key is never without a window in between. Null for a window that shows nothing.
167
218
  */
168
- move: (window) => {
219
+ move: (window, at) => {
169
220
  const entry = entries.get(window.id);
170
221
  if (!entry || entry.key === null) {
171
222
  return null;
172
223
  }
173
224
  const key = entry.key;
174
- entry.key = null;
175
- return create(key, 'opened');
225
+ release(entry, key);
226
+ return create(key, 'opened', null, at);
227
+ },
228
+ /*
229
+ * The keys a window keeps besides the one it shows, such as its tabs. A key another window shows
230
+ * or keeps stays that window's, so the answer is the keys this window has now.
231
+ */
232
+ hold: (window, keys) => {
233
+ const entry = entries.get(window.id);
234
+ if (!entry) {
235
+ return [];
236
+ }
237
+ entry.held = [...new Set(keys)].filter((key) => {
238
+ const holder = holderOf(key);
239
+ return holder === null || holder === entry;
240
+ });
241
+ return entry.held;
242
+ },
243
+ /*
244
+ * Takes a key a window shows or keeps into a new window of its own, at `at` when given, as when
245
+ * a tab is dragged out. Null when the window does not have the key.
246
+ */
247
+ detach: (window, key, at) => {
248
+ const entry = entries.get(window.id);
249
+ if (!entry || (entry.key !== key && !entry.held.includes(key))) {
250
+ return null;
251
+ }
252
+ release(entry, key);
253
+ writeSession();
254
+ return create(key, 'opened', null, at);
255
+ },
256
+ /*
257
+ * Hands a key to another window to keep, out of whichever window had it, as when a tab is dropped
258
+ * on another window. That window's page then shows it with `claim`.
259
+ */
260
+ transfer: (key, window) => {
261
+ const target = entries.get(window.id);
262
+ if (!target) {
263
+ return false;
264
+ }
265
+ const holder = holderOf(key);
266
+ if (holder !== null && holder !== target) {
267
+ release(holder, key);
268
+ }
269
+ if (!target.held.includes(key)) {
270
+ target.held = [...target.held, key];
271
+ }
272
+ writeSession();
273
+ return true;
274
+ },
275
+ /* The window that shows or keeps a key, if any. */
276
+ holderOf: (key) => holderOf(key)?.window ?? null,
277
+ /* 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
+ }
288
+ }
289
+ return null;
290
+ },
291
+ /*
292
+ * What a window's page keeps of itself across a quit, opaque to the shell, such as the tabs it
293
+ * had. The session hands it back to `create` for that window; null keeps nothing.
294
+ */
295
+ setState: (window, state) => {
296
+ const entry = entries.get(window.id);
297
+ if (!entry || entry.state === state) {
298
+ return;
299
+ }
300
+ entry.state = state;
301
+ writeSession();
176
302
  },
177
303
  /* 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`. */
178
304
  fromContents: (contents) => {
@@ -194,15 +320,34 @@ export const createWindows = (options) => {
194
320
  }
195
321
  }
196
322
  },
197
- /* From the moment the app quits, windows that close stay in the session, so the next start opens them again. */
323
+ /*
324
+ * From the moment the app quits, windows that close stay in the session, so the next start opens
325
+ * them again. `attach` calls it for a quit that goes ahead; an app only needs it without `attach`.
326
+ */
198
327
  quit: markQuit,
328
+ /*
329
+ * The updater's `onQuit`: true right before an install quits the app, after which the windows
330
+ * it closes stay in the session; false when the install failed and the app stays.
331
+ */
332
+ quitForUpdate: (going) => {
333
+ if (going) {
334
+ installing = true;
335
+ markQuit();
336
+ }
337
+ else {
338
+ stay();
339
+ }
340
+ },
199
341
  /*
200
342
  * What every app with this set of windows does with its app's events: a second instance raises
201
343
  * the window in front, a click on the dock icon with no window open opens the last session's
202
- * again, pending bounds are written at quit, and off macOS the app quits with its last window
203
- * while that window stays in the session. Call it before `app.whenReady()`, and `restore()` once ready.
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.
204
348
  */
205
- attach: (app) => {
349
+ attach: (app, attachOptions = {}) => {
350
+ const { confirmQuit } = attachOptions;
206
351
  const platform = options.platform ?? process.platform;
207
352
  quitsWithLastWindow = platform !== 'darwin';
208
353
  app.on('second-instance', () => {
@@ -219,6 +364,29 @@ export const createWindows = (options) => {
219
364
  restore();
220
365
  }
221
366
  });
367
+ app.on('before-quit', (event) => {
368
+ if (confirmQuit === undefined || quitConfirmed) {
369
+ quitEvent = event;
370
+ return;
371
+ }
372
+ event.preventDefault();
373
+ if (confirming) {
374
+ return;
375
+ }
376
+ confirming = true;
377
+ void new Promise((resolve) => resolve(confirmQuit({ update: installing })))
378
+ .catch(() => true)
379
+ .then((go) => {
380
+ confirming = false;
381
+ if (!go) {
382
+ stay();
383
+ return;
384
+ }
385
+ quitConfirmed = true;
386
+ // A quit asked from inside this event would be undone by the one still running, so it waits for a task of its own.
387
+ later(() => app.quit(), 0);
388
+ });
389
+ });
222
390
  app.on('will-quit', () => options.state.flush());
223
391
  app.on('window-all-closed', () => {
224
392
  if (quitsWithLastWindow) {
@@ -226,10 +394,7 @@ export const createWindows = (options) => {
226
394
  }
227
395
  });
228
396
  },
229
- /* The quit did not go ahead after all, such as an update that failed to install: a window that closes leaves the session again. */
230
- resume: () => {
231
- quitting = false;
232
- writeSession();
233
- }
397
+ /* The quit did not go ahead after all: a window that closes leaves the session again. `attach` calls it when `confirmQuit` says no. */
398
+ resume
234
399
  };
235
400
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/shell",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
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",
@@ -52,7 +52,7 @@
52
52
  "devDependencies": {
53
53
  "@types/bun": "^1.4.2",
54
54
  "@types/node": "^24.13.6",
55
- "electron": "^44.4.4",
55
+ "electron": "^44.7.0",
56
56
  "electron-updater": "^6.8.9",
57
57
  "typescript": "^7.0.2"
58
58
  }
@@ -3,7 +3,7 @@
3
3
  * a preload and a page read them as well as the main process.
4
4
  */
5
5
 
6
- export { MENU_ROLES, type MenuNode, type MenuRole, type MenuSpec } from './menu.ts';
6
+ export { MENU_BAR_ROLES, MENU_ROLES, type MenuBarRole, type MenuNode, type MenuRole, type MenuSpec } from './menu.ts';
7
7
  export type { ThemeState } from './theme.ts';
8
- export type { UpdateState } from './update.ts';
8
+ export type { UpdateErrorCode, UpdateState } from './update.ts';
9
9
  export { compareVersions, isVersion } from './versions.ts';
@@ -27,6 +27,12 @@ export const MENU_ROLES = [
27
27
 
28
28
  export type MenuRole = (typeof MENU_ROLES)[number];
29
29
 
30
+ /* The roles a whole menu in the bar can take. On macOS `window` is where the system lists the open
31
+ windows and `help` gets the system's search field. */
32
+ export const MENU_BAR_ROLES = ['window', 'help'] as const;
33
+
34
+ export type MenuBarRole = (typeof MENU_BAR_ROLES)[number];
35
+
30
36
  /* `Action` names what only the shell can carry out; an app lists its own. */
31
37
  export type MenuNode<Action extends string = string> =
32
38
  | {
@@ -48,5 +54,5 @@ export type MenuNode<Action extends string = string> =
48
54
  | { kind: 'submenu'; id: string; label: string; items: MenuNode<Action>[]; enabled?: boolean };
49
55
 
50
56
  export interface MenuSpec<Action extends string = string> {
51
- menus: { id: string; label: string; items: MenuNode<Action>[] }[];
57
+ menus: { id: string; label: string; role?: MenuBarRole; items: MenuNode<Action>[] }[];
52
58
  }
@@ -1,3 +1,6 @@
1
+ /* Why updating failed, for the page to put in its own words: `no-feed` is a 404 on the release feed, which has no published release yet or is private. */
2
+ export type UpdateErrorCode = 'no-feed' | 'failed';
3
+
1
4
  /* Where updating stands, as the shell keeps it and the page draws it. */
2
5
  export interface UpdateState {
3
6
  /* `unsupported` is a checkout, which has no feed; `current` means a check found nothing newer. */
@@ -6,5 +9,7 @@ export interface UpdateState {
6
9
  /* The version on the other side, once a check has seen one. */
7
10
  version?: string;
8
11
  percent?: number;
12
+ /* The first line of what went wrong, as electron-updater said it, in English and untranslated; null when it said nothing. */
9
13
  error?: string | null;
14
+ errorCode?: UpdateErrorCode | null;
10
15
  }
package/src/index.ts CHANGED
@@ -13,4 +13,4 @@ export type { SavedWindow, StateWindow, WindowDisplay, WindowSize, WindowState,
13
13
  export { createTheme } from './theme.ts';
14
14
  export type { Theme, ThemeOptions, TitleBarOverlay } from './theme.ts';
15
15
  export { CASCADE, createWindows, UNKEYED_STATE } from './windows.ts';
16
- export type { WindowOrigin, Windows, WindowsOptions } from './windows.ts';
16
+ export type { AttachOptions, QuitRequest, WindowOrigin, WindowPoint, Windows, WindowsOptions } from './windows.ts';
package/src/menu.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { MenuItemConstructorOptions } from 'electron';
2
- import { MENU_ROLES, type MenuNode, type MenuSpec } from './bridge/menu.ts';
2
+ import { MENU_BAR_ROLES, MENU_ROLES, type MenuNode, type MenuSpec } from './bridge/menu.ts';
3
3
  import type { PageKeys } from './page-keys.ts';
4
4
 
5
5
  /* Named instead of the role: that one follows the focused web contents, which is a guest page as soon as one has the keyboard. */
@@ -44,7 +44,11 @@ export const menuTemplateOf = <Action extends string>(spec: MenuSpec<Action>, op
44
44
  };
45
45
  const itemsOf = (nodes: readonly MenuNode<Action>[]): MenuItemConstructorOptions[] =>
46
46
  nodes.map(itemOf).filter((item): item is MenuItemConstructorOptions => item !== null);
47
- return spec.menus.map((menu) => ({ label: menu.label, submenu: itemsOf(menu.items) }));
47
+ return spec.menus.map((menu) => ({
48
+ label: menu.label,
49
+ ...(menu.role !== undefined && MENU_BAR_ROLES.includes(menu.role) ? { role: menu.role } : {}),
50
+ submenu: itemsOf(menu.items)
51
+ }));
48
52
  };
49
53
 
50
54
  export interface StaticMenuOptions {
@@ -54,24 +58,29 @@ export interface StaticMenuOptions {
54
58
  appItems?: MenuItemConstructorOptions[];
55
59
  /* Right above Quit, on every platform. */
56
60
  quitItems?: MenuItemConstructorOptions[];
61
+ /* A File menu of their own, such as New Window, which a macOS app with no window open can only reach here. Above the quit items elsewhere. */
62
+ fileItems?: MenuItemConstructorOptions[];
57
63
  platform?: NodeJS.Platform;
58
64
  }
59
65
 
66
+ const withSeparator = (items: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] => (items.length > 0 ? [...items, { type: 'separator' }] : []);
67
+
60
68
  /*
61
- * The menu that stands until the page sends its own, and again after a reload or a crash, so Quit is
62
- * always there. The stock View menu without reload and the zoom roles: their accelerators are taken
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
63
71
  * before the page sees them, and a reload would drop the page's state without asking.
64
72
  */
65
73
  export const staticMenuTemplate = (options: StaticMenuOptions): MenuItemConstructorOptions[] => {
66
74
  const platform = options.platform ?? process.platform;
67
75
  const appItems = options.appItems ?? [];
68
76
  const quitItems = options.quitItems ?? [];
77
+ const fileItems = options.fileItems ?? [];
69
78
  const appMenu: MenuItemConstructorOptions =
70
79
  platform === 'darwin'
71
80
  ? {
72
81
  label: options.appName,
73
82
  submenu: [
74
- ...(appItems.length > 0 ? [...appItems, { type: 'separator' } as const] : []),
83
+ ...withSeparator(appItems),
75
84
  { role: 'services' },
76
85
  { type: 'separator' },
77
86
  { role: 'hide' },
@@ -82,9 +91,10 @@ export const staticMenuTemplate = (options: StaticMenuOptions): MenuItemConstruc
82
91
  { role: 'quit' }
83
92
  ]
84
93
  }
85
- : quitItems.length > 0
86
- ? { label: 'File', submenu: [...quitItems, { type: 'separator' }, { role: 'quit' }] }
94
+ : fileItems.length > 0 || quitItems.length > 0
95
+ ? { label: 'File', submenu: [...withSeparator(fileItems), ...withSeparator(quitItems), { role: 'quit' }] }
87
96
  : { role: 'fileMenu' };
97
+ const fileMenu: MenuItemConstructorOptions[] = platform === 'darwin' && fileItems.length > 0 ? [{ label: 'File', submenu: fileItems }] : [];
88
98
  const viewMenu: MenuItemConstructorOptions = {
89
99
  label: 'View',
90
100
  submenu: [
@@ -93,7 +103,7 @@ export const staticMenuTemplate = (options: StaticMenuOptions): MenuItemConstruc
93
103
  { role: 'togglefullscreen' }
94
104
  ]
95
105
  };
96
- return [appMenu, { role: 'editMenu' }, viewMenu, { role: 'windowMenu' }];
106
+ return [appMenu, ...fileMenu, { role: 'editMenu' }, viewMenu, { role: 'windowMenu' }];
97
107
  };
98
108
 
99
109
  /* The part of a window a menu command reaches. */
package/src/updater.ts CHANGED
@@ -1,23 +1,23 @@
1
1
  import type { AppUpdater } from 'electron-updater';
2
- import type { UpdateState } from './bridge/update.ts';
2
+ import type { UpdateErrorCode, UpdateState } from './bridge/update.ts';
3
3
 
4
4
  /* Often enough that a release lands the same day, rarely enough to be invisible. */
5
5
  export const UPDATE_INTERVAL_MS = 60 * 60 * 1000;
6
6
 
7
7
  /*
8
8
  * electron-updater puts the whole HTTP exchange in the message of a failed check: every response
9
- * header, the session cookie among them. Only the first line travels, and a 404 on the feed gets the
10
- * sentence that says what went wrong.
9
+ * header, the session cookie among them. Only the first line travels. The words are the page's, in
10
+ * the person's language, so the shell sends a code beside that line and no sentence of its own.
11
11
  */
12
- export const describeUpdateError = (message: string): string => {
12
+ export const describeUpdateError = (message: string): { code: UpdateErrorCode; detail: string | null } => {
13
13
  const first = message.split('\n')[0]?.trim();
14
14
  if (!first) {
15
- return 'No reason given.';
15
+ return { code: 'failed', detail: null };
16
16
  }
17
- if (first.startsWith('404')) {
18
- return 'No release feed found. There is no published release yet, or the repository is private.';
19
- }
20
- return first.length > 200 ? `${first.slice(0, 200)}…` : first;
17
+ return {
18
+ code: first.startsWith('404') ? 'no-feed' : 'failed',
19
+ detail: first.length > 200 ? `${first.slice(0, 200)}…` : first
20
+ };
21
21
  };
22
22
 
23
23
  export interface UpdaterOptions {
@@ -33,7 +33,7 @@ export interface UpdaterOptions {
33
33
  /*
34
34
  * True right before installing quits the app, false when that install failed and the app stays.
35
35
  * electron-updater closes the windows before the app's own `before-quit`, so this is where a set of
36
- * windows learns the quit is coming (`windows.quit()` and `windows.resume()`).
36
+ * windows learns the quit is coming: pass `windows.quitForUpdate`.
37
37
  */
38
38
  onQuit?: (quitting: boolean) => void;
39
39
  log?: (message: string, error: unknown) => void;
@@ -91,14 +91,15 @@ export const createUpdater = (options: UpdaterOptions) => {
91
91
  // The page owns the preference and sends it before the first check, so nothing downloads
92
92
  // behind the back of someone who turned it off.
93
93
  loaded.autoDownload = false;
94
- loaded.on('checking-for-update', () => setState({ status: 'checking', error: null }));
95
- loaded.on('update-available', (info) => setState({ status: 'available', version: info.version, error: null }));
96
- loaded.on('update-not-available', () => setState({ status: 'current', version: undefined, error: null }));
94
+ loaded.on('checking-for-update', () => setState({ status: 'checking', error: null, errorCode: null }));
95
+ loaded.on('update-available', (info) => setState({ status: 'available', version: info.version, error: null, errorCode: null }));
96
+ loaded.on('update-not-available', () => setState({ status: 'current', version: undefined, error: null, errorCode: null }));
97
97
  loaded.on('download-progress', (progress) => setState({ status: 'downloading', percent: progress.percent }));
98
98
  loaded.on('update-downloaded', (info) => setState({ status: 'ready', version: info.version, percent: 100 }));
99
99
  loaded.on('error', (e) => {
100
100
  installFailed();
101
- setState({ status: 'error', error: describeUpdateError(e.message) });
101
+ const { code, detail } = describeUpdateError(e.message);
102
+ setState({ status: 'error', error: detail, errorCode: code });
102
103
  });
103
104
  updater = loaded;
104
105
  setState({ status: 'idle' });
package/src/windows.ts CHANGED
@@ -14,34 +14,78 @@ export interface WindowsOptions {
14
14
  state: WindowState;
15
15
  /* Which windows were open when the app last quit, so a cold start opens them again. */
16
16
  session: WindowStateStorage;
17
- /* Builds the app's window for a key and loads it. The bounds go into the constructor. */
18
- create: (key: string | null, bounds: Partial<Rectangle> & WindowSize, origin: WindowOrigin) => BrowserWindow;
17
+ /*
18
+ * Builds the app's window for a key and loads it. The bounds go into the constructor. `state` is
19
+ * what the window's page last handed `setState` before a quit, on a window the session opens again.
20
+ */
21
+ create: (key: string | null, bounds: Partial<Rectangle> & WindowSize, origin: WindowOrigin, state: string | null) => BrowserWindow;
19
22
  /* A window came to the front, after `focused()` already answers it, such as to draw its own menu. */
20
23
  onFront?: (window: BrowserWindow) => void;
21
24
  /* The key an app kept its one window under before it had several, where a first window without bounds of its own opens. */
22
25
  formerKey?: string;
23
26
  platform?: NodeJS.Platform;
27
+ /* The timer that asks for the quit again once `confirmQuit` said yes, replaced in a test. */
28
+ setTimeout?: (run: () => void, ms: number) => unknown;
29
+ }
30
+
31
+ /* What a quit `confirmQuit` decides on is about. `update` is a quit an install started, after it closed the windows. */
32
+ export interface QuitRequest {
33
+ update: boolean;
34
+ }
35
+
36
+ export interface AttachOptions {
37
+ /*
38
+ * Asked once per quit, before any window closes; the quit waits while the answer is pending, so this
39
+ * is also where the app finishes what has to end first. False keeps the app open. A throw lets the
40
+ * quit go ahead, so a broken check never traps the app.
41
+ */
42
+ confirmQuit?: (request: QuitRequest) => boolean | Promise<boolean>;
24
43
  }
25
44
 
26
45
  interface Entry {
27
46
  window: BrowserWindow;
28
47
  key: string | null;
48
+ state: string | null;
49
+ /* The keys the window keeps besides the one it shows, such as its tabs, which no other window may show. */
50
+ held: string[];
51
+ }
52
+
53
+ /* Where a new window's top left corner goes on the screen, such as under the pointer that dragged something out. */
54
+ export interface WindowPoint {
55
+ x: number;
56
+ y: number;
57
+ }
58
+
59
+ interface SessionWindow {
60
+ key: string | null;
61
+ state: string | null;
29
62
  }
30
63
 
31
64
  const SESSION_VERSION = 1;
32
65
 
33
- const parseSession = (text: string | null): (string | null)[] => {
66
+ /*
67
+ * The states sit in a list of their own beside the keys, at the same places, so a shell from before
68
+ * them still reads the keys and opens the windows without what their pages kept.
69
+ */
70
+ const parseSession = (text: string | null): SessionWindow[] => {
34
71
  if (text === null) {
35
72
  return [];
36
73
  }
37
74
  try {
38
- const file = JSON.parse(text) as { version?: unknown; windows?: unknown };
75
+ const file = JSON.parse(text) as { version?: unknown; windows?: unknown; states?: unknown };
39
76
  if (file.version !== SESSION_VERSION || !Array.isArray(file.windows)) {
40
77
  return [];
41
78
  }
42
- const keys = file.windows.filter((key): key is string | null => key === null || (typeof key === 'string' && key !== ''));
79
+ const states: unknown[] = Array.isArray(file.states) ? file.states : [];
80
+ const windows = file.windows.flatMap((key: unknown, index): SessionWindow[] => {
81
+ if (key !== null && (typeof key !== 'string' || key === '')) {
82
+ return [];
83
+ }
84
+ const state = states[index];
85
+ return [{ key, state: typeof state === 'string' ? state : null }];
86
+ });
43
87
  // A key belongs to one window, so a file that lists one twice keeps the last.
44
- return keys.filter((key, index) => key === null || keys.lastIndexOf(key) === index);
88
+ return windows.filter(({ key }, index) => key === null || windows.findLastIndex((other) => other.key === key) === index);
45
89
  } catch {
46
90
  return [];
47
91
  }
@@ -63,6 +107,12 @@ export const createWindows = (options: WindowsOptions) => {
63
107
  let started = false;
64
108
  // Off macOS the app quits with its last window, after that window already left the session.
65
109
  let quitsWithLastWindow = false;
110
+ // The app's last `before-quit`. Its windows stay in the session as they close, unless a listener after ours kept the app open.
111
+ let quitEvent: { readonly defaultPrevented: boolean } | null = null;
112
+ let quitConfirmed = false;
113
+ let confirming = false;
114
+ let installing = false;
115
+ const later = options.setTimeout ?? ((run, ms) => setTimeout(run, ms));
66
116
 
67
117
  const alive = (): Entry[] => [...entries.values()].filter((entry) => !entry.window.isDestroyed());
68
118
 
@@ -70,8 +120,11 @@ export const createWindows = (options: WindowsOptions) => {
70
120
  if (quitting) {
71
121
  return;
72
122
  }
73
- const keys = order.map((id) => entries.get(id)?.key ?? null);
74
- options.session.write(`${JSON.stringify({ version: SESSION_VERSION, windows: keys }, null, 2)}\n`);
123
+ const shown = order.map((id) => entries.get(id));
124
+ const keys = shown.map((entry) => entry?.key ?? null);
125
+ 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 };
127
+ options.session.write(`${JSON.stringify(file, null, 2)}\n`);
75
128
  };
76
129
 
77
130
  const toFront = (id: number): void => {
@@ -88,7 +141,15 @@ export const createWindows = (options: WindowsOptions) => {
88
141
  return null;
89
142
  };
90
143
 
91
- const holderOf = (key: string): Entry | null => alive().find((entry) => entry.key === key) ?? null;
144
+ const holderOf = (key: string): Entry | null => alive().find((entry) => entry.key === key || entry.held.includes(key)) ?? null;
145
+
146
+ /* Takes a key out of the window that has it, shown or kept. */
147
+ const release = (entry: Entry, key: string): void => {
148
+ if (entry.key === key) {
149
+ entry.key = null;
150
+ }
151
+ entry.held = entry.held.filter((other) => other !== key);
152
+ };
92
153
 
93
154
  const pageWindow = (contents: WebContents): BrowserWindow | null => alive().find((entry) => entry.window.webContents === contents)?.window ?? null;
94
155
 
@@ -113,9 +174,10 @@ export const createWindows = (options: WindowsOptions) => {
113
174
  return options.state.bounds(stateKey, { ...beside, x: beside.x + CASCADE, y: beside.y + CASCADE });
114
175
  };
115
176
 
116
- const create = (key: string | null, origin: WindowOrigin): BrowserWindow => {
117
- const window = options.create(key, boundsFor(key), origin);
118
- const entry: Entry = { window, key };
177
+ const create = (key: string | null, origin: WindowOrigin, state: string | null = null, at?: WindowPoint): BrowserWindow => {
178
+ const bounds = boundsFor(key);
179
+ const window = options.create(key, at === undefined ? bounds : { ...bounds, x: Math.round(at.x), y: Math.round(at.y) }, origin, state);
180
+ const entry: Entry = { window, key, state, held: [] };
119
181
  const id = window.id;
120
182
  entries.set(id, entry);
121
183
  toFront(id);
@@ -125,9 +187,16 @@ export const createWindows = (options: WindowsOptions) => {
125
187
  toFront(id);
126
188
  options.onFront?.(window);
127
189
  });
128
- window.on('close', () => {
190
+ window.on('close', (event) => {
129
191
  if (quitsWithLastWindow && alive().length === 1) {
130
192
  markQuit();
193
+ } else if (quitEvent !== null && !quitEvent.defaultPrevented) {
194
+ if (event.defaultPrevented) {
195
+ // A window that stays open stops Electron's quit, so a window that closes later is a person's doing.
196
+ quitEvent = null;
197
+ } else {
198
+ markQuit();
199
+ }
131
200
  }
132
201
  });
133
202
  window.on('closed', () => {
@@ -154,26 +223,42 @@ export const createWindows = (options: WindowsOptions) => {
154
223
  };
155
224
 
156
225
  const restore = (): void => {
157
- const keys = parseSession(options.session.read());
226
+ const windows = parseSession(options.session.read());
158
227
  started = true;
159
- if (keys.length === 0) {
228
+ if (windows.length === 0) {
160
229
  create(null, 'first');
161
230
  return;
162
231
  }
163
- for (const key of keys) {
164
- create(key, 'session');
232
+ for (const { key, state } of windows) {
233
+ create(key, 'session', state);
165
234
  }
166
235
  };
167
236
 
237
+ const resume = (): void => {
238
+ quitting = false;
239
+ quitEvent = null;
240
+ quitConfirmed = false;
241
+ installing = false;
242
+ writeSession();
243
+ };
244
+
245
+ /* The quit does not happen. Windows it already closed, the last one off macOS or all of them for an install, come back as they were. */
246
+ const stay = (): void => {
247
+ if (quitting && started && alive().length === 0) {
248
+ restore();
249
+ }
250
+ resume();
251
+ };
252
+
168
253
  return {
169
- /* Raises the window that has the key, or opens one for it. Null always opens a new window. */
170
- open: (key: string | null): BrowserWindow => {
254
+ /* Raises the window that has the key, or opens one for it, at `at` when given. Null always opens a new window. */
255
+ open: (key: string | null, at?: WindowPoint): BrowserWindow => {
171
256
  const holder = key === null ? null : holderOf(key);
172
257
  if (holder) {
173
258
  raise(holder.window);
174
259
  return holder.window;
175
260
  }
176
- return create(key, 'opened');
261
+ return create(key, 'opened', null, at);
177
262
  },
178
263
 
179
264
  /* Opens the windows that were open when the app last quit, or one without a key. */
@@ -181,7 +266,7 @@ export const createWindows = (options: WindowsOptions) => {
181
266
 
182
267
  /*
183
268
  * Makes a window the one that shows a key, as when its page opens a project. False when another
184
- * window already has it; that window comes to the front instead. Null lets the key go.
269
+ * window already shows or keeps it; that window comes to the front instead. Null lets the key go.
185
270
  */
186
271
  claim: (window: BrowserWindow, key: string | null): boolean => {
187
272
  const entry = entries.get(window.id);
@@ -208,14 +293,95 @@ export const createWindows = (options: WindowsOptions) => {
208
293
  * Moves what a window shows into a new window, which comes to the front, and leaves the old one
209
294
  * without a key. The key is never without a window in between. Null for a window that shows nothing.
210
295
  */
211
- move: (window: BrowserWindow): BrowserWindow | null => {
296
+ move: (window: BrowserWindow, at?: WindowPoint): BrowserWindow | null => {
212
297
  const entry = entries.get(window.id);
213
298
  if (!entry || entry.key === null) {
214
299
  return null;
215
300
  }
216
301
  const key = entry.key;
217
- entry.key = null;
218
- return create(key, 'opened');
302
+ release(entry, key);
303
+ return create(key, 'opened', null, at);
304
+ },
305
+
306
+ /*
307
+ * The keys a window keeps besides the one it shows, such as its tabs. A key another window shows
308
+ * or keeps stays that window's, so the answer is the keys this window has now.
309
+ */
310
+ hold: (window: BrowserWindow, keys: readonly string[]): string[] => {
311
+ const entry = entries.get(window.id);
312
+ if (!entry) {
313
+ return [];
314
+ }
315
+ entry.held = [...new Set(keys)].filter((key) => {
316
+ const holder = holderOf(key);
317
+ return holder === null || holder === entry;
318
+ });
319
+ return entry.held;
320
+ },
321
+
322
+ /*
323
+ * Takes a key a window shows or keeps into a new window of its own, at `at` when given, as when
324
+ * a tab is dragged out. Null when the window does not have the key.
325
+ */
326
+ detach: (window: BrowserWindow, key: string, at?: WindowPoint): BrowserWindow | null => {
327
+ const entry = entries.get(window.id);
328
+ if (!entry || (entry.key !== key && !entry.held.includes(key))) {
329
+ return null;
330
+ }
331
+ release(entry, key);
332
+ writeSession();
333
+ return create(key, 'opened', null, at);
334
+ },
335
+
336
+ /*
337
+ * Hands a key to another window to keep, out of whichever window had it, as when a tab is dropped
338
+ * on another window. That window's page then shows it with `claim`.
339
+ */
340
+ transfer: (key: string, window: BrowserWindow): boolean => {
341
+ const target = entries.get(window.id);
342
+ if (!target) {
343
+ return false;
344
+ }
345
+ const holder = holderOf(key);
346
+ if (holder !== null && holder !== target) {
347
+ release(holder, key);
348
+ }
349
+ if (!target.held.includes(key)) {
350
+ target.held = [...target.held, key];
351
+ }
352
+ writeSession();
353
+ return true;
354
+ },
355
+
356
+ /* The window that shows or keeps a key, if any. */
357
+ holderOf: (key: string): BrowserWindow | null => holderOf(key)?.window ?? null,
358
+
359
+ /* 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;
369
+ }
370
+ }
371
+ return null;
372
+ },
373
+
374
+ /*
375
+ * What a window's page keeps of itself across a quit, opaque to the shell, such as the tabs it
376
+ * had. The session hands it back to `create` for that window; null keeps nothing.
377
+ */
378
+ setState: (window: BrowserWindow, state: string | null): void => {
379
+ const entry = entries.get(window.id);
380
+ if (!entry || entry.state === state) {
381
+ return;
382
+ }
383
+ entry.state = state;
384
+ writeSession();
219
385
  },
220
386
 
221
387
  /* 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`. */
@@ -243,16 +409,35 @@ export const createWindows = (options: WindowsOptions) => {
243
409
  }
244
410
  },
245
411
 
246
- /* From the moment the app quits, windows that close stay in the session, so the next start opens them again. */
412
+ /*
413
+ * From the moment the app quits, windows that close stay in the session, so the next start opens
414
+ * them again. `attach` calls it for a quit that goes ahead; an app only needs it without `attach`.
415
+ */
247
416
  quit: markQuit,
248
417
 
418
+ /*
419
+ * The updater's `onQuit`: true right before an install quits the app, after which the windows
420
+ * it closes stay in the session; false when the install failed and the app stays.
421
+ */
422
+ quitForUpdate: (going: boolean): void => {
423
+ if (going) {
424
+ installing = true;
425
+ markQuit();
426
+ } else {
427
+ stay();
428
+ }
429
+ },
430
+
249
431
  /*
250
432
  * What every app with this set of windows does with its app's events: a second instance raises
251
433
  * the window in front, a click on the dock icon with no window open opens the last session's
252
- * again, pending bounds are written at quit, and off macOS the app quits with its last window
253
- * while that window stays in the session. Call it before `app.whenReady()`, and `restore()` once ready.
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.
254
438
  */
255
- attach: (app: Pick<App, 'on' | 'quit'>): void => {
439
+ attach: (app: Pick<App, 'on' | 'quit'>, attachOptions: AttachOptions = {}): void => {
440
+ const { confirmQuit } = attachOptions;
256
441
  const platform = options.platform ?? process.platform;
257
442
  quitsWithLastWindow = platform !== 'darwin';
258
443
  app.on('second-instance', () => {
@@ -268,6 +453,29 @@ export const createWindows = (options: WindowsOptions) => {
268
453
  restore();
269
454
  }
270
455
  });
456
+ app.on('before-quit', (event) => {
457
+ if (confirmQuit === undefined || quitConfirmed) {
458
+ quitEvent = event;
459
+ return;
460
+ }
461
+ event.preventDefault();
462
+ if (confirming) {
463
+ return;
464
+ }
465
+ confirming = true;
466
+ void new Promise<boolean>((resolve) => resolve(confirmQuit({ update: installing })))
467
+ .catch(() => true)
468
+ .then((go) => {
469
+ confirming = false;
470
+ if (!go) {
471
+ stay();
472
+ return;
473
+ }
474
+ quitConfirmed = true;
475
+ // 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
+ later(() => app.quit(), 0);
477
+ });
478
+ });
271
479
  app.on('will-quit', () => options.state.flush());
272
480
  app.on('window-all-closed', () => {
273
481
  if (quitsWithLastWindow) {
@@ -276,11 +484,8 @@ export const createWindows = (options: WindowsOptions) => {
276
484
  });
277
485
  },
278
486
 
279
- /* The quit did not go ahead after all, such as an update that failed to install: a window that closes leaves the session again. */
280
- resume: (): void => {
281
- quitting = false;
282
- writeSession();
283
- }
487
+ /* The quit did not go ahead after all: a window that closes leaves the session again. `attach` calls it when `confirmQuit` says no. */
488
+ resume
284
489
  };
285
490
  };
286
491