@adecore/shell 0.0.0-stage → 0.14.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/LICENSE +21 -0
- package/README.md +42 -2
- package/dist/bridge/index.d.ts +4 -0
- package/dist/bridge/index.js +6 -0
- package/dist/bridge/menu.d.ts +35 -0
- package/dist/bridge/menu.js +25 -0
- package/dist/bridge/theme.d.ts +5 -0
- package/dist/bridge/theme.js +1 -0
- package/dist/bridge/update.d.ts +7 -0
- package/dist/bridge/update.js +1 -0
- package/dist/bridge/versions.d.ts +2 -0
- package/dist/bridge/versions.js +22 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +7 -0
- package/dist/menu.d.ts +31 -0
- package/dist/menu.js +88 -0
- package/dist/page-keys.d.ts +11 -0
- package/dist/page-keys.js +25 -0
- package/dist/theme.d.ts +26 -0
- package/dist/theme.js +37 -0
- package/dist/updater.d.ts +23 -0
- package/dist/updater.js +120 -0
- package/dist/web-guards.d.ts +11 -0
- package/dist/web-guards.js +41 -0
- package/dist/window-state.d.ts +42 -0
- package/dist/window-state.js +157 -0
- package/dist/windows.d.ts +29 -0
- package/dist/windows.js +234 -0
- package/package.json +57 -4
- package/src/bridge/index.ts +9 -0
- package/src/bridge/menu.ts +52 -0
- package/src/bridge/theme.ts +8 -0
- package/src/bridge/update.ts +10 -0
- package/src/bridge/versions.ts +25 -0
- package/src/index.ts +14 -0
- package/src/menu.ts +131 -0
- package/src/page-keys.ts +35 -0
- package/src/theme.ts +66 -0
- package/src/updater.ts +152 -0
- package/src/web-guards.ts +55 -0
- package/src/window-state.ts +214 -0
- package/src/windows.ts +285 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bas Milius
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @adecore/shell
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@adecore/shell)
|
|
4
|
+
[](https://adecore.dev/shell/)
|
|
5
|
+
|
|
6
|
+
The main process of an Electron app whose page draws its own interface. The page builds the application menu and shows where updating stands. The shell turns that menu into a native one and runs electron-updater. It also opens each window where it was left, puts the page's theme on the window and keeps the page's bridge in the app's own frame.
|
|
7
|
+
|
|
8
|
+
**[Documentation](https://adecore.dev/shell/)**
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bun add @adecore/shell
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Electron 44 or later and electron-updater are optional peer dependencies; the app brings its own. The package imports only their types, so a test runs in Bun without Electron.
|
|
17
|
+
|
|
18
|
+
## Nothing listens on its own
|
|
19
|
+
|
|
20
|
+
The package registers no IPC handler and listens to no app-wide Electron event. Every IPC message from the page crosses a security boundary, so the app wires each channel itself, behind its own check of the sender (`isAppSender`), and calls into the package from there.
|
|
21
|
+
|
|
22
|
+
## Entry points
|
|
23
|
+
|
|
24
|
+
| Import | What it holds |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `@adecore/shell` | For the main process: `createWindows`, `createWindowState`, `createUpdater`, `createTheme`, `menuTemplateOf`, `staticMenuTemplate`, `createMenuCommands`, `createPageKeys` 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. |
|
|
28
|
+
|
|
29
|
+
## Documentation
|
|
30
|
+
|
|
31
|
+
| Page | What it covers |
|
|
32
|
+
|---|---|
|
|
33
|
+
| [Application menu](https://adecore.dev/shell/menu) | The menu the page builds, the one that stands until it does, and how a command gets back to the page |
|
|
34
|
+
| [Updater](https://adecore.dev/shell/updater) | electron-updater as a state the page watches |
|
|
35
|
+
| [Windows](https://adecore.dev/shell/windows) | A set of windows, one per key, restored at the next start |
|
|
36
|
+
| [Window state](https://adecore.dev/shell/window-state) | Each window opens where it was left |
|
|
37
|
+
| [Theme](https://adecore.dev/shell/theme) | The page's theme on the window, its controls and Chromium |
|
|
38
|
+
| [Web guards](https://adecore.dev/shell/web-guards) | Where the app's page may go, and who may speak for it |
|
|
39
|
+
| [Bridge](https://adecore.dev/shell/bridge) | The shapes that cross IPC |
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The shapes that cross IPC between the shell and the page it hosts. Nothing here imports Electron, so
|
|
3
|
+
* a preload and a page read them as well as the main process.
|
|
4
|
+
*/
|
|
5
|
+
export { MENU_ROLES } from './menu.js';
|
|
6
|
+
export { compareVersions, isVersion } from './versions.js';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare const MENU_ROLES: readonly ['undo', 'redo', 'cut', 'copy', 'paste', 'selectAll', 'services', 'hide', 'hideOthers', 'unhide', 'minimize', 'zoom', 'front', 'close', 'quit', 'togglefullscreen'];
|
|
2
|
+
export type MenuRole = (typeof MENU_ROLES)[number];
|
|
3
|
+
export type MenuNode<Action extends string = string> = {
|
|
4
|
+
kind: 'command';
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
accelerator?: string;
|
|
8
|
+
keys?: string;
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
checked?: boolean;
|
|
11
|
+
radio?: boolean;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'role';
|
|
14
|
+
role: MenuRole;
|
|
15
|
+
label: string;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'shell';
|
|
18
|
+
action: Action;
|
|
19
|
+
label: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'separator';
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'submenu';
|
|
24
|
+
id: string;
|
|
25
|
+
label: string;
|
|
26
|
+
items: MenuNode<Action>[];
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
};
|
|
29
|
+
export interface MenuSpec<Action extends string = string> {
|
|
30
|
+
menus: {
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
items: MenuNode<Action>[];
|
|
34
|
+
}[];
|
|
35
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The application menu, built by the page from what has the focus and drawn by the shell as a native
|
|
3
|
+
* menu. The shell only knows these shapes; a click on a command comes back as its id, and the page
|
|
4
|
+
* runs it.
|
|
5
|
+
*/
|
|
6
|
+
/* The native roles the shell accepts. Each still carries a label from the page, so the whole menu is
|
|
7
|
+
in the language of the interface and not half in the system's. */
|
|
8
|
+
export const MENU_ROLES = [
|
|
9
|
+
'undo',
|
|
10
|
+
'redo',
|
|
11
|
+
'cut',
|
|
12
|
+
'copy',
|
|
13
|
+
'paste',
|
|
14
|
+
'selectAll',
|
|
15
|
+
'services',
|
|
16
|
+
'hide',
|
|
17
|
+
'hideOthers',
|
|
18
|
+
'unhide',
|
|
19
|
+
'minimize',
|
|
20
|
+
'zoom',
|
|
21
|
+
'front',
|
|
22
|
+
'close',
|
|
23
|
+
'quit',
|
|
24
|
+
'togglefullscreen'
|
|
25
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The order the shell sorts the release list in has to be the order the page reads it in, or the
|
|
3
|
+
* notes of a version land under the wrong heading. One comparison, so the two cannot disagree.
|
|
4
|
+
*/
|
|
5
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
6
|
+
/* A plain `1.2.3`. A git tag carries a `v` in front of it, which is the tag's and not the version's. */
|
|
7
|
+
export const isVersion = (version) => SEMVER.test(version);
|
|
8
|
+
/* Negative when `a` is older than `b`. A version that is not semver sorts below every one that is. */
|
|
9
|
+
export const compareVersions = (a, b) => {
|
|
10
|
+
const left = SEMVER.exec(a);
|
|
11
|
+
const right = SEMVER.exec(b);
|
|
12
|
+
if (!left || !right) {
|
|
13
|
+
return (left ? 1 : 0) - (right ? 1 : 0);
|
|
14
|
+
}
|
|
15
|
+
for (let i = 1; i <= 3; i++) {
|
|
16
|
+
const difference = Number(left[i]) - Number(right[i]);
|
|
17
|
+
if (difference !== 0) {
|
|
18
|
+
return difference;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return 0;
|
|
22
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { createMenuCommands, devToolsAccelerator, menuTemplateOf, staticMenuTemplate } from './menu.ts';
|
|
2
|
+
export type { MenuCommandOptions, MenuTemplateOptions, MenuWindow, StaticMenuOptions } from './menu.ts';
|
|
3
|
+
export { createPageKeys, PAGE_KEY_MS } from './page-keys.ts';
|
|
4
|
+
export type { PageKey, PageKeys } from './page-keys.ts';
|
|
5
|
+
export { createUpdater, describeUpdateError, UPDATE_INTERVAL_MS } from './updater.ts';
|
|
6
|
+
export type { Updater, UpdaterOptions } from './updater.ts';
|
|
7
|
+
export { appWindowNavigation, isAppSender, isAppUrl, isExternalLink, isWebLink, originOf } from './web-guards.ts';
|
|
8
|
+
export type { NavigationVerdict, SenderFrame } from './web-guards.ts';
|
|
9
|
+
export { createWindowState, fileStorage, fitBounds, VISIBLE_EDGE } from './window-state.ts';
|
|
10
|
+
export type { SavedWindow, StateWindow, WindowDisplay, WindowSize, WindowState, WindowStateOptions, WindowStateStorage } from './window-state.ts';
|
|
11
|
+
export { createTheme } from './theme.ts';
|
|
12
|
+
export type { Theme, ThemeOptions, TitleBarOverlay } from './theme.ts';
|
|
13
|
+
export { CASCADE, createWindows, UNKEYED_STATE } from './windows.ts';
|
|
14
|
+
export type { WindowOrigin, Windows, WindowsOptions } from './windows.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createMenuCommands, devToolsAccelerator, menuTemplateOf, staticMenuTemplate } from './menu.js';
|
|
2
|
+
export { createPageKeys, PAGE_KEY_MS } from './page-keys.js';
|
|
3
|
+
export { createUpdater, describeUpdateError, UPDATE_INTERVAL_MS } from './updater.js';
|
|
4
|
+
export { appWindowNavigation, isAppSender, isAppUrl, isExternalLink, isWebLink, originOf } from './web-guards.js';
|
|
5
|
+
export { createWindowState, fileStorage, fitBounds, VISIBLE_EDGE } from './window-state.js';
|
|
6
|
+
export { createTheme } from './theme.js';
|
|
7
|
+
export { CASCADE, createWindows, UNKEYED_STATE } from './windows.js';
|
package/dist/menu.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { MenuItemConstructorOptions } from 'electron';
|
|
2
|
+
import { type MenuSpec } from './bridge/menu.ts';
|
|
3
|
+
import type { PageKeys } from './page-keys.ts';
|
|
4
|
+
export declare const devToolsAccelerator: (platform?: NodeJS.Platform) => string;
|
|
5
|
+
export interface MenuTemplateOptions<Action extends string> {
|
|
6
|
+
run: (id: string, byKey: boolean) => void;
|
|
7
|
+
shellItem: (action: Action, label: string) => MenuItemConstructorOptions | null;
|
|
8
|
+
platform?: NodeJS.Platform;
|
|
9
|
+
}
|
|
10
|
+
export declare const menuTemplateOf: <Action extends string>(spec: MenuSpec<Action>, options: MenuTemplateOptions<Action>) => MenuItemConstructorOptions[] | null;
|
|
11
|
+
export interface StaticMenuOptions {
|
|
12
|
+
appName: string;
|
|
13
|
+
toggleDevTools: () => void;
|
|
14
|
+
appItems?: MenuItemConstructorOptions[];
|
|
15
|
+
quitItems?: MenuItemConstructorOptions[];
|
|
16
|
+
platform?: NodeJS.Platform;
|
|
17
|
+
}
|
|
18
|
+
export declare const staticMenuTemplate: (options: StaticMenuOptions) => MenuItemConstructorOptions[];
|
|
19
|
+
export interface MenuWindow {
|
|
20
|
+
isMinimized(): boolean;
|
|
21
|
+
show(): void;
|
|
22
|
+
readonly webContents: {
|
|
23
|
+
send(channel: string, ...args: unknown[]): void;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export interface MenuCommandOptions {
|
|
27
|
+
pageKeys: PageKeys;
|
|
28
|
+
window: () => MenuWindow | null;
|
|
29
|
+
keyBypassesPage?: () => boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare const createMenuCommands: (options: MenuCommandOptions) => (id: string, byKey: boolean) => void;
|
package/dist/menu.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { MENU_ROLES } from './bridge/menu.js';
|
|
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
|
+
export const devToolsAccelerator = (platform = process.platform) => (platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I');
|
|
4
|
+
/* The native menu of a spec the page sent, or null for a spec that is no menu at all. */
|
|
5
|
+
export const menuTemplateOf = (spec, options) => {
|
|
6
|
+
if (!Array.isArray(spec?.menus)) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const platform = options.platform ?? process.platform;
|
|
10
|
+
const itemOf = (node) => {
|
|
11
|
+
switch (node.kind) {
|
|
12
|
+
case 'separator':
|
|
13
|
+
return { type: 'separator' };
|
|
14
|
+
case 'submenu':
|
|
15
|
+
return { label: node.label, enabled: node.enabled ?? true, submenu: itemsOf(node.items) };
|
|
16
|
+
case 'role':
|
|
17
|
+
return MENU_ROLES.includes(node.role) ? { role: node.role, label: node.label } : null;
|
|
18
|
+
case 'shell':
|
|
19
|
+
return options.shellItem(node.action, node.label);
|
|
20
|
+
case 'command':
|
|
21
|
+
return {
|
|
22
|
+
label: node.label,
|
|
23
|
+
type: node.checked === undefined ? 'normal' : node.radio === true ? 'radio' : 'checkbox',
|
|
24
|
+
checked: node.checked ?? false,
|
|
25
|
+
enabled: node.enabled ?? true,
|
|
26
|
+
...(node.accelerator ? { accelerator: node.accelerator } : {}),
|
|
27
|
+
// Off macOS a registered accelerator would take a key such as Ctrl+W before the page saw it.
|
|
28
|
+
registerAccelerator: platform === 'darwin',
|
|
29
|
+
click: (_item, _window, event) => options.run(node.id, event.triggeredByAccelerator === true)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const itemsOf = (nodes) => nodes.map(itemOf).filter((item) => item !== null);
|
|
34
|
+
return spec.menus.map((menu) => ({ label: menu.label, submenu: itemsOf(menu.items) }));
|
|
35
|
+
};
|
|
36
|
+
/*
|
|
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
|
|
39
|
+
* before the page sees them, and a reload would drop the page's state without asking.
|
|
40
|
+
*/
|
|
41
|
+
export const staticMenuTemplate = (options) => {
|
|
42
|
+
const platform = options.platform ?? process.platform;
|
|
43
|
+
const appItems = options.appItems ?? [];
|
|
44
|
+
const quitItems = options.quitItems ?? [];
|
|
45
|
+
const appMenu = platform === 'darwin'
|
|
46
|
+
? {
|
|
47
|
+
label: options.appName,
|
|
48
|
+
submenu: [
|
|
49
|
+
...(appItems.length > 0 ? [...appItems, { type: 'separator' }] : []),
|
|
50
|
+
{ role: 'services' },
|
|
51
|
+
{ type: 'separator' },
|
|
52
|
+
{ role: 'hide' },
|
|
53
|
+
{ role: 'hideOthers' },
|
|
54
|
+
{ role: 'unhide' },
|
|
55
|
+
{ type: 'separator' },
|
|
56
|
+
...quitItems,
|
|
57
|
+
{ role: 'quit' }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
: quitItems.length > 0
|
|
61
|
+
? { label: 'File', submenu: [...quitItems, { type: 'separator' }, { role: 'quit' }] }
|
|
62
|
+
: { role: 'fileMenu' };
|
|
63
|
+
const viewMenu = {
|
|
64
|
+
label: 'View',
|
|
65
|
+
submenu: [
|
|
66
|
+
{ label: 'Toggle Developer Tools', accelerator: devToolsAccelerator(platform), click: () => options.toggleDevTools() },
|
|
67
|
+
{ type: 'separator' },
|
|
68
|
+
{ role: 'togglefullscreen' }
|
|
69
|
+
]
|
|
70
|
+
};
|
|
71
|
+
return [appMenu, { role: 'editMenu' }, viewMenu, { role: 'windowMenu' }];
|
|
72
|
+
};
|
|
73
|
+
/*
|
|
74
|
+
* Runs a menu command in the page (`menu:run`), which runs it the way its own palette does. One fired by
|
|
75
|
+
* a key the page already had is dropped: its own listeners answered it or let it pass on purpose. Only a
|
|
76
|
+
* minimized window is shown: showing activates the app, and a pick from behind the person's work must
|
|
77
|
+
* leave it there.
|
|
78
|
+
*/
|
|
79
|
+
export const createMenuCommands = (options) => (id, byKey) => {
|
|
80
|
+
if (byKey && !(options.keyBypassesPage?.() ?? false) && options.pageKeys.take()) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const window = options.window();
|
|
84
|
+
if (window?.isMinimized()) {
|
|
85
|
+
window.show();
|
|
86
|
+
}
|
|
87
|
+
window?.webContents.send('menu:run', id);
|
|
88
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface PageKey {
|
|
2
|
+
type: string;
|
|
3
|
+
meta: boolean;
|
|
4
|
+
control: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare const PAGE_KEY_MS = 1000;
|
|
7
|
+
export declare const createPageKeys: (now?: () => number) => {
|
|
8
|
+
saw: (key: PageKey) => void;
|
|
9
|
+
take: () => boolean;
|
|
10
|
+
};
|
|
11
|
+
export type PageKeys = ReturnType<typeof createPageKeys>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/* How long after the page had a key the menu may still be answering it: the page's round trip, with room for a busy one. */
|
|
2
|
+
export const PAGE_KEY_MS = 1000;
|
|
3
|
+
/*
|
|
4
|
+
* Whether a menu item that Electron says its accelerator fired answers a key the page already had.
|
|
5
|
+
* Electron says so for every pick that is not a mouse click, so an item picked through accessibility
|
|
6
|
+
* or with the keyboard inside the menu reads as fired by its key as well, and a key sent to the app
|
|
7
|
+
* while none of its windows is key reaches the menu without the page ever seeing it. Only a key with
|
|
8
|
+
* Cmd or Ctrl counts, since the menu binds no other.
|
|
9
|
+
*/
|
|
10
|
+
export const createPageKeys = (now = Date.now) => {
|
|
11
|
+
// One entry per key, so a held key that repeats before the menu answers the first still answers each.
|
|
12
|
+
let seen = [];
|
|
13
|
+
return {
|
|
14
|
+
saw: (key) => {
|
|
15
|
+
if (key.type === 'keyDown' && (key.meta || key.control)) {
|
|
16
|
+
seen.push(now());
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
take: () => {
|
|
20
|
+
const since = now() - PAGE_KEY_MS;
|
|
21
|
+
seen = seen.filter((at) => at >= since);
|
|
22
|
+
return seen.shift() !== undefined;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
};
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { BrowserWindow, BrowserWindowConstructorOptions, NativeTheme } from 'electron';
|
|
2
|
+
import type { ThemeState } from './bridge/theme.ts';
|
|
3
|
+
export interface TitleBarOverlay {
|
|
4
|
+
height: number;
|
|
5
|
+
colors: Record<ThemeState['resolved'], {
|
|
6
|
+
color: string;
|
|
7
|
+
symbolColor: string;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
export interface ThemeOptions {
|
|
11
|
+
nativeTheme: Pick<NativeTheme, 'themeSource'>;
|
|
12
|
+
windows: () => readonly BrowserWindow[];
|
|
13
|
+
background: string;
|
|
14
|
+
overlay?: TitleBarOverlay;
|
|
15
|
+
trafficLights?: {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
};
|
|
19
|
+
platform?: NodeJS.Platform;
|
|
20
|
+
}
|
|
21
|
+
export declare const createTheme: (options: ThemeOptions) => {
|
|
22
|
+
apply: (theme: ThemeState) => void;
|
|
23
|
+
current: () => ThemeState | null;
|
|
24
|
+
windowOptions: () => BrowserWindowConstructorOptions;
|
|
25
|
+
};
|
|
26
|
+
export type Theme = ReturnType<typeof createTheme>;
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The theme the pages report, applied to what only the shell can paint: the ground of every window, so
|
|
3
|
+
* a reload or a resize never flashes the other theme, the native controls off macOS, and the
|
|
4
|
+
* `prefers-color-scheme` Chromium answers, which follows the app instead of the system it runs on.
|
|
5
|
+
* A new window opens in the theme the others are in.
|
|
6
|
+
*/
|
|
7
|
+
export const createTheme = (options) => {
|
|
8
|
+
const platform = options.platform ?? process.platform;
|
|
9
|
+
let current = null;
|
|
10
|
+
const overlayOf = (resolved) => options.overlay ? { height: options.overlay.height, ...options.overlay.colors[resolved] } : undefined;
|
|
11
|
+
return {
|
|
12
|
+
apply: (theme) => {
|
|
13
|
+
current = theme;
|
|
14
|
+
options.nativeTheme.themeSource = theme.followsSystem ? 'system' : theme.resolved;
|
|
15
|
+
for (const window of options.windows()) {
|
|
16
|
+
if (window.isDestroyed()) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const overlay = overlayOf(theme.resolved);
|
|
20
|
+
if (platform !== 'darwin' && overlay) {
|
|
21
|
+
window.setTitleBarOverlay(overlay);
|
|
22
|
+
}
|
|
23
|
+
window.setBackgroundColor(theme.background);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
current: () => current,
|
|
27
|
+
/* The constructor options of a new window: its own chrome and the ground the others have. Dark until a page says otherwise. */
|
|
28
|
+
windowOptions: () => {
|
|
29
|
+
const resolved = current?.resolved ?? 'dark';
|
|
30
|
+
const overlay = overlayOf(resolved);
|
|
31
|
+
const chrome = platform === 'darwin'
|
|
32
|
+
? { titleBarStyle: 'hiddenInset', trafficLightPosition: options.trafficLights ?? { x: 17, y: 17 } }
|
|
33
|
+
: { titleBarStyle: 'hidden', ...(overlay ? { titleBarOverlay: overlay } : {}) };
|
|
34
|
+
return { ...chrome, backgroundColor: current?.background ?? options.background };
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AppUpdater } from 'electron-updater';
|
|
2
|
+
import type { UpdateState } from './bridge/update.ts';
|
|
3
|
+
export declare const UPDATE_INTERVAL_MS: number;
|
|
4
|
+
export declare const describeUpdateError: (message: string) => string;
|
|
5
|
+
export interface UpdaterOptions {
|
|
6
|
+
currentVersion: string;
|
|
7
|
+
packaged: boolean;
|
|
8
|
+
load: () => AppUpdater;
|
|
9
|
+
publish: (state: UpdateState) => void;
|
|
10
|
+
beforeCheck?: () => void;
|
|
11
|
+
onQuit?: (quitting: boolean) => void;
|
|
12
|
+
log?: (message: string, error: unknown) => void;
|
|
13
|
+
setInterval?: (run: () => void, ms: number) => unknown;
|
|
14
|
+
}
|
|
15
|
+
export declare const createUpdater: (options: UpdaterOptions) => {
|
|
16
|
+
state: () => UpdateState;
|
|
17
|
+
start: () => void;
|
|
18
|
+
configure: (autoDownload: unknown) => void;
|
|
19
|
+
check: () => Promise<void>;
|
|
20
|
+
download: () => Promise<void>;
|
|
21
|
+
install: () => boolean;
|
|
22
|
+
};
|
|
23
|
+
export type Updater = ReturnType<typeof createUpdater>;
|
package/dist/updater.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/* Often enough that a release lands the same day, rarely enough to be invisible. */
|
|
2
|
+
export const UPDATE_INTERVAL_MS = 60 * 60 * 1000;
|
|
3
|
+
/*
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
export const describeUpdateError = (message) => {
|
|
9
|
+
const first = message.split('\n')[0]?.trim();
|
|
10
|
+
if (!first) {
|
|
11
|
+
return 'No reason given.';
|
|
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;
|
|
17
|
+
};
|
|
18
|
+
/*
|
|
19
|
+
* The updater as a state machine the page watches, not a dialog that interrupts. In a checkout the
|
|
20
|
+
* state stays `unsupported`, so nothing in the page offers to update.
|
|
21
|
+
*/
|
|
22
|
+
export const createUpdater = (options) => {
|
|
23
|
+
const log = options.log ?? ((message, error) => console.error(message, error));
|
|
24
|
+
const every = options.setInterval ?? ((run, ms) => setInterval(run, ms));
|
|
25
|
+
let state = { status: 'unsupported', currentVersion: options.currentVersion };
|
|
26
|
+
let updater = null;
|
|
27
|
+
let timer = null;
|
|
28
|
+
let installing = false;
|
|
29
|
+
const setState = (patch) => {
|
|
30
|
+
state = { ...state, ...patch };
|
|
31
|
+
options.publish(state);
|
|
32
|
+
};
|
|
33
|
+
const installFailed = () => {
|
|
34
|
+
if (installing) {
|
|
35
|
+
installing = false;
|
|
36
|
+
options.onQuit?.(false);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const check = async () => {
|
|
40
|
+
// Nothing to learn while a check or a download runs, and a build already waiting to be installed
|
|
41
|
+
// does not get better for being asked about again.
|
|
42
|
+
if (!updater || state.status === 'checking' || state.status === 'downloading' || state.status === 'ready') {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
options.beforeCheck?.();
|
|
46
|
+
try {
|
|
47
|
+
await updater.checkForUpdates();
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
// checkForUpdates rejects as well as emitting `error`; the state is already set there.
|
|
51
|
+
log('Update check failed', e);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
state: () => state,
|
|
56
|
+
start: () => {
|
|
57
|
+
if (!options.packaged) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const loaded = options.load();
|
|
62
|
+
// The page owns the preference and sends it before the first check, so nothing downloads
|
|
63
|
+
// behind the back of someone who turned it off.
|
|
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 }));
|
|
68
|
+
loaded.on('download-progress', (progress) => setState({ status: 'downloading', percent: progress.percent }));
|
|
69
|
+
loaded.on('update-downloaded', (info) => setState({ status: 'ready', version: info.version, percent: 100 }));
|
|
70
|
+
loaded.on('error', (e) => {
|
|
71
|
+
installFailed();
|
|
72
|
+
setState({ status: 'error', error: describeUpdateError(e.message) });
|
|
73
|
+
});
|
|
74
|
+
updater = loaded;
|
|
75
|
+
setState({ status: 'idle' });
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
// electron-updater missing from the bundle is the only way here; the app stays as it is.
|
|
79
|
+
log('The updater did not start', e);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
/* The hourly check starts with the first preference the page sends, never before: until then the shell does not know whether it may download what a check turns up. */
|
|
83
|
+
configure: (autoDownload) => {
|
|
84
|
+
if (!updater) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
updater.autoDownload = autoDownload === true;
|
|
88
|
+
timer ??= every(() => void check(), UPDATE_INTERVAL_MS);
|
|
89
|
+
},
|
|
90
|
+
check,
|
|
91
|
+
download: async () => {
|
|
92
|
+
if (!updater) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
await updater.downloadUpdate();
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
log('Update download failed', e);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
/* Quits into the downloaded build. False when there is none, or the install failed at once. */
|
|
103
|
+
install: () => {
|
|
104
|
+
if (!updater || state.status !== 'ready') {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
installing = true;
|
|
108
|
+
options.onQuit?.(true);
|
|
109
|
+
try {
|
|
110
|
+
updater.quitAndInstall();
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
log('Update install failed', e);
|
|
115
|
+
installFailed();
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const originOf: (url: string, schemes?: readonly string[]) => string | null;
|
|
2
|
+
export declare const isAppUrl: (url: string, appOrigin: string, schemes?: readonly string[]) => boolean;
|
|
3
|
+
export declare const isWebLink: (url: string) => boolean;
|
|
4
|
+
export declare const isExternalLink: (url: string) => boolean;
|
|
5
|
+
export type NavigationVerdict = 'allow' | 'external' | 'refuse';
|
|
6
|
+
export declare const appWindowNavigation: (url: string, appOrigin: string, schemes?: readonly string[]) => NavigationVerdict;
|
|
7
|
+
export interface SenderFrame {
|
|
8
|
+
readonly url: string;
|
|
9
|
+
readonly parent: unknown;
|
|
10
|
+
}
|
|
11
|
+
export declare const isAppSender: (isAppWindow: boolean, frame: SenderFrame | null, appOrigin: string, schemes?: readonly string[]) => boolean;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What the shell lets the app's page do, as pure decisions, so the main process only wires them to
|
|
3
|
+
* Electron's events. The app's page carries the whole bridge, so each rule here is a security boundary.
|
|
4
|
+
*
|
|
5
|
+
* `schemes` lists the standard schemes an app registers for itself (`app://`): Node's URL gives every
|
|
6
|
+
* scheme it does not know the origin `null`, where Chromium gives a registered standard scheme a real one.
|
|
7
|
+
*/
|
|
8
|
+
/* The origin of a URL, or null for one that does not parse or has no origin of its own (`about:`, `data:`). */
|
|
9
|
+
export const originOf = (url, schemes = []) => {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = new URL(url);
|
|
12
|
+
const scheme = parsed.protocol.slice(0, -1);
|
|
13
|
+
if (schemes.includes(scheme)) {
|
|
14
|
+
return parsed.host ? `${scheme}://${parsed.host.toLowerCase()}` : null;
|
|
15
|
+
}
|
|
16
|
+
return parsed.origin === 'null' ? null : parsed.origin;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
export const isAppUrl = (url, appOrigin, schemes = []) => originOf(url, schemes) === appOrigin;
|
|
23
|
+
export const isWebLink = (url) => /^https?:\/\//i.test(url);
|
|
24
|
+
/* What the system browser or mail app may be handed; a `file:` link could open an application. */
|
|
25
|
+
export const isExternalLink = (url) => isWebLink(url) || /^mailto:/i.test(url);
|
|
26
|
+
/*
|
|
27
|
+
* Where the app window's own page may go. A dropped link or file navigates the top frame, and the page
|
|
28
|
+
* it lands on would inherit the bridge, so only the app stays in the window and a web link leaves for
|
|
29
|
+
* the system browser.
|
|
30
|
+
*/
|
|
31
|
+
export const appWindowNavigation = (url, appOrigin, schemes = []) => {
|
|
32
|
+
if (isAppUrl(url, appOrigin, schemes)) {
|
|
33
|
+
return 'allow';
|
|
34
|
+
}
|
|
35
|
+
return isWebLink(url) ? 'external' : 'refuse';
|
|
36
|
+
};
|
|
37
|
+
/*
|
|
38
|
+
* Whether an IPC message came from the app itself: the top frame of an app window, still on the app's
|
|
39
|
+
* origin. The web contents alone says nothing, since it stays the same object whatever it navigates to.
|
|
40
|
+
*/
|
|
41
|
+
export const isAppSender = (isAppWindow, frame, appOrigin, schemes = []) => isAppWindow && frame !== null && frame.parent === null && isAppUrl(frame.url, appOrigin, schemes);
|