@adecore/shell 0.23.1 → 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 +1 -1
- package/dist/bridge/index.d.ts +2 -2
- package/dist/bridge/index.js +1 -1
- package/dist/bridge/menu.d.ts +3 -0
- package/dist/bridge/menu.js +3 -0
- package/dist/bridge/update.d.ts +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/menu.d.ts +1 -0
- package/dist/menu.js +15 -8
- package/dist/updater.d.ts +5 -2
- package/dist/updater.js +12 -11
- package/dist/windows.d.ts +22 -4
- package/dist/windows.js +194 -29
- package/package.json +1 -1
- package/src/bridge/index.ts +2 -2
- package/src/bridge/menu.ts +7 -1
- package/src/bridge/update.ts +5 -0
- package/src/index.ts +1 -1
- package/src/menu.ts +18 -8
- package/src/updater.ts +15 -14
- package/src/windows.ts +238 -33
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
|
|
package/dist/bridge/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/bridge/index.js
CHANGED
|
@@ -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';
|
package/dist/bridge/menu.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/bridge/menu.js
CHANGED
|
@@ -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'];
|
package/dist/bridge/update.d.ts
CHANGED
|
@@ -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) => ({
|
|
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,
|
|
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
|
|
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: [...
|
|
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) =>
|
|
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
|
|
6
|
-
*
|
|
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 '
|
|
11
|
+
return { code: 'failed', detail: null };
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
45
|
-
|
|
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
|
|
82
|
-
const
|
|
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
|
|
156
|
+
const windows = parseSession(options.session.read());
|
|
120
157
|
started = true;
|
|
121
|
-
if (
|
|
158
|
+
if (windows.length === 0) {
|
|
122
159
|
create(null, 'first');
|
|
123
160
|
return;
|
|
124
161
|
}
|
|
125
|
-
for (const key of
|
|
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
|
|
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
|
|
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
|
-
/*
|
|
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,
|
|
203
|
-
*
|
|
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
|
|
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
package/src/bridge/index.ts
CHANGED
|
@@ -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';
|
package/src/bridge/menu.ts
CHANGED
|
@@ -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
|
}
|
package/src/bridge/update.ts
CHANGED
|
@@ -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) => ({
|
|
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,
|
|
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
|
|
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: [...
|
|
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
|
|
10
|
-
*
|
|
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 '
|
|
15
|
+
return { code: 'failed', detail: null };
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/*
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
74
|
-
|
|
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
|
|
118
|
-
const
|
|
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
|
|
226
|
+
const windows = parseSession(options.session.read());
|
|
158
227
|
started = true;
|
|
159
|
-
if (
|
|
228
|
+
if (windows.length === 0) {
|
|
160
229
|
create(null, 'first');
|
|
161
230
|
return;
|
|
162
231
|
}
|
|
163
|
-
for (const key of
|
|
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
|
|
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
|
|
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
|
-
/*
|
|
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,
|
|
253
|
-
*
|
|
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'
|
|
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
|
|
280
|
-
resume
|
|
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
|
|