@deckbuilderkit/shell 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +25 -4
- package/src/app-window.ts +65 -0
- package/src/flags.ts +87 -0
- package/src/idle.ts +38 -0
- package/src/index.ts +318 -0
- package/src/instance.ts +166 -0
- package/src/link.ts +16 -0
- package/src/urls.ts +7 -0
- package/src/webview-bun.d.ts +31 -0
- package/src/webview-zoom.ts +26 -0
- package/src/window-worker.ts +91 -0
- package/src/window.ts +57 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deckbuilderkit/shell",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/adrianogpena/deckbuilder.git",
|
|
8
|
+
"directory": "packages/shell"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/*.test.ts"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts",
|
|
20
|
+
"./window-worker": "./src/window-worker.ts"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@deckbuilderkit/platform": "0.1.1",
|
|
24
|
+
"@deckbuilderkit/server-core": "0.1.1",
|
|
25
|
+
"webview-bun": "^2.4.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Subprocess } from 'bun';
|
|
2
|
+
import { appBrowserArgv } from '@deckbuilderkit/platform';
|
|
3
|
+
import { DEFAULT_WINDOW, type WindowOptions } from './window.ts';
|
|
4
|
+
|
|
5
|
+
export interface AppWindowOptions {
|
|
6
|
+
/** Edge or Chrome, from `findAppBrowser()`. */
|
|
7
|
+
browser: string;
|
|
8
|
+
/** Browser profile of the app; one profile means one browser process for all app windows. */
|
|
9
|
+
profileDir: string;
|
|
10
|
+
window?: WindowOptions;
|
|
11
|
+
/** A launch became the browser's main process: its exit will report "all closed". */
|
|
12
|
+
onMainStarted?: () => void;
|
|
13
|
+
/** A launch handed its window to a browser process that isn't tracked (e.g. left over from a crash). */
|
|
14
|
+
onUntracked?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A launch that stays up this long is the browser's main process; one that exits sooner handed
|
|
18
|
+
// its window to a browser process already running on the same profile.
|
|
19
|
+
const MAIN_AFTER_MS = 3000;
|
|
20
|
+
|
|
21
|
+
let main: Subprocess | null = null;
|
|
22
|
+
let pending = 0;
|
|
23
|
+
|
|
24
|
+
/** True while the tracked browser process runs or a launch hasn't settled yet. */
|
|
25
|
+
export function appWindowsOpen(): boolean {
|
|
26
|
+
return main !== null || pending > 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Opens `url` in an Edge/Chrome app window. `onAllClosed` runs when the main browser process
|
|
31
|
+
* exits, which means every app window of the profile is closed.
|
|
32
|
+
*/
|
|
33
|
+
export function openAppWindow(url: string, opts: AppWindowOptions, onAllClosed: () => void): void {
|
|
34
|
+
const size = { width: opts.window?.width ?? DEFAULT_WINDOW.width, height: opts.window?.height ?? DEFAULT_WINDOW.height };
|
|
35
|
+
const proc = Bun.spawn(appBrowserArgv(opts.browser, url, opts.profileDir, size), { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
36
|
+
pending++;
|
|
37
|
+
let settled = false;
|
|
38
|
+
const settle = () => {
|
|
39
|
+
if (settled) return false;
|
|
40
|
+
settled = true;
|
|
41
|
+
pending--;
|
|
42
|
+
return true;
|
|
43
|
+
};
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
if (!settle() || main) return;
|
|
46
|
+
main = proc;
|
|
47
|
+
opts.onMainStarted?.();
|
|
48
|
+
}, MAIN_AFTER_MS);
|
|
49
|
+
void proc.exited.then(() => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
if (proc === main) {
|
|
52
|
+
main = null;
|
|
53
|
+
onAllClosed();
|
|
54
|
+
} else if (settle() && !main) opts.onUntracked?.();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Closes the tracked browser process gracefully, so its profile isn't marked as crashed. */
|
|
59
|
+
export function closeAppWindows(): void {
|
|
60
|
+
if (!main) return;
|
|
61
|
+
if (process.platform === 'win32') {
|
|
62
|
+
Bun.spawn(['taskkill', '/PID', String(main.pid)], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
|
|
63
|
+
} else main.kill('SIGTERM');
|
|
64
|
+
main = null;
|
|
65
|
+
}
|
package/src/flags.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type AppIdentity, DEV_PORT } from '@deckbuilderkit/server-core';
|
|
2
|
+
|
|
3
|
+
/** The command line of an app, as `runApp` reads it. */
|
|
4
|
+
export interface Flags {
|
|
5
|
+
/** The requested way to open the UI; runApp falls back from 'app' to 'browser' without Edge/Chrome. */
|
|
6
|
+
mode: 'window' | 'app' | 'browser' | 'none';
|
|
7
|
+
noOpen: boolean;
|
|
8
|
+
port?: number;
|
|
9
|
+
idleExitMinutes?: number;
|
|
10
|
+
dev: boolean;
|
|
11
|
+
help: boolean;
|
|
12
|
+
openUrl?: string;
|
|
13
|
+
afterUpdate: boolean;
|
|
14
|
+
window: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parses the app's arguments (without the executable and script); unknown ones are ignored.
|
|
19
|
+
* With `--open-url` every other flag is ignored: a link holding a raw `"` can inject arguments.
|
|
20
|
+
*/
|
|
21
|
+
export function parseFlags(argv: string[]): Flags | { error: string } {
|
|
22
|
+
const flag = (name: string) => argv.includes(name);
|
|
23
|
+
const option = (name: string) => {
|
|
24
|
+
const i = argv.indexOf(name);
|
|
25
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const openUrl = option('--open-url');
|
|
29
|
+
if (openUrl !== undefined) {
|
|
30
|
+
return { mode: 'window', noOpen: false, dev: false, help: false, openUrl, afterUpdate: false, window: false };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let port: number | undefined;
|
|
34
|
+
if (flag('--port')) {
|
|
35
|
+
const value = option('--port') ?? '';
|
|
36
|
+
port = /^\d{1,5}$/.test(value) ? Number(value) : NaN;
|
|
37
|
+
if (!(port <= 65535)) return { error: '--port wants a port number from 0 to 65535.' };
|
|
38
|
+
}
|
|
39
|
+
let idleExitMinutes: number | undefined;
|
|
40
|
+
if (flag('--idle-exit')) {
|
|
41
|
+
idleExitMinutes = Number(option('--idle-exit'));
|
|
42
|
+
if (!(idleExitMinutes > 0)) return { error: '--idle-exit wants a number of minutes greater than 0.' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dev = flag('--dev');
|
|
46
|
+
const noOpen = flag('--no-open');
|
|
47
|
+
const window = flag('--window');
|
|
48
|
+
const mode: Flags['mode'] = flag('--browser') ? (noOpen ? 'none' : 'browser')
|
|
49
|
+
: flag('--app') ? (noOpen ? 'none' : 'app')
|
|
50
|
+
: dev && !window ? 'none' : 'window';
|
|
51
|
+
return {
|
|
52
|
+
mode,
|
|
53
|
+
noOpen,
|
|
54
|
+
port,
|
|
55
|
+
idleExitMinutes,
|
|
56
|
+
dev,
|
|
57
|
+
help: flag('--help') || flag('-h'),
|
|
58
|
+
afterUpdate: flag('--after-update'),
|
|
59
|
+
window,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function helpText(app: AppIdentity): string {
|
|
64
|
+
return `${app.name}
|
|
65
|
+
|
|
66
|
+
Usage: ${app.id} [--app | --browser [--no-open]] [--port <n>] [--idle-exit <min>] [--dev [--window | --app]]
|
|
67
|
+
${app.id} --open-url <uri>
|
|
68
|
+
|
|
69
|
+
--app open the UI in an Edge/Chrome app window (falls back to --browser when
|
|
70
|
+
neither is installed)
|
|
71
|
+
--browser serve the UI to the default browser instead of opening a window; the
|
|
72
|
+
Settings screen has a Quit button
|
|
73
|
+
--no-open with --browser: print the address only
|
|
74
|
+
--port <n> fixed port (default: any free port)
|
|
75
|
+
--idle-exit <min> exit after <min> minutes without a request from an open UI (fractions
|
|
76
|
+
allowed; the exe in --browser mode without a tray icon uses 10 without
|
|
77
|
+
the flag)
|
|
78
|
+
--open-url <uri> open a native window on <path> of a ${app.id}://open/<path> link
|
|
79
|
+
(Windows passes deep links this way)
|
|
80
|
+
--dev port ${DEV_PORT} for the Vite dev server (opens nothing unless --window, --app
|
|
81
|
+
or --browser is given; never exits when idle). The printed URL survives
|
|
82
|
+
reloads; after a watch restart open the newly printed one
|
|
83
|
+
|
|
84
|
+
When ${app.name} already runs (outside --dev), a new launch opens a window or browser tab of
|
|
85
|
+
the running instance instead.
|
|
86
|
+
`;
|
|
87
|
+
}
|
package/src/idle.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
type Interval = ReturnType<typeof setInterval>;
|
|
2
|
+
|
|
3
|
+
export interface IdleWatchOptions {
|
|
4
|
+
timeoutMs: number;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
setInterval?: (fn: () => void, ms: number) => Interval;
|
|
7
|
+
clearInterval?: (id: Interval) => void;
|
|
8
|
+
onIdle: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface IdleWatch {
|
|
12
|
+
/** Marks activity: the timeout starts over. */
|
|
13
|
+
touch(): void;
|
|
14
|
+
stop(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Calls `onIdle` once when `touch` wasn't called for `timeoutMs`; checks every min(30 s, timeout / 2). */
|
|
18
|
+
export function createIdleWatch(opts: IdleWatchOptions): IdleWatch {
|
|
19
|
+
const now = opts.now ?? Date.now;
|
|
20
|
+
const set = opts.setInterval ?? ((fn: () => void, ms: number) => setInterval(fn, ms));
|
|
21
|
+
const clear = opts.clearInterval ?? ((id: Interval) => clearInterval(id));
|
|
22
|
+
let last = now();
|
|
23
|
+
let id: Interval | null = set(() => {
|
|
24
|
+
if (now() - last < opts.timeoutMs) return;
|
|
25
|
+
stop();
|
|
26
|
+
opts.onIdle();
|
|
27
|
+
}, Math.min(30_000, opts.timeoutMs / 2));
|
|
28
|
+
function stop() {
|
|
29
|
+
if (id !== null) clear(id);
|
|
30
|
+
id = null;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
touch: () => {
|
|
34
|
+
last = now();
|
|
35
|
+
},
|
|
36
|
+
stop,
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
type AppIdentity,
|
|
5
|
+
DEV_PORT,
|
|
6
|
+
embeddedWebAssets,
|
|
7
|
+
type IndexTransform,
|
|
8
|
+
isUrlScheme,
|
|
9
|
+
launchCodes,
|
|
10
|
+
type Routes,
|
|
11
|
+
type SocketRoutes,
|
|
12
|
+
startServer,
|
|
13
|
+
VITE_PORT,
|
|
14
|
+
} from '@deckbuilderkit/server-core';
|
|
15
|
+
import { appHome, createTray, createUrlProtocol, findAppBrowser, isCompiledExe, openExternal, removeOldExe, type Tray, waitForUrl } from '@deckbuilderkit/platform';
|
|
16
|
+
import { appWindowsOpen, closeAppWindows, openAppWindow } from './app-window.ts';
|
|
17
|
+
import { helpText, parseFlags } from './flags.ts';
|
|
18
|
+
import { createIdleWatch, type IdleWatch } from './idle.ts';
|
|
19
|
+
import { acquireLock, createInstance, handOff, type OpenMode } from './instance.ts';
|
|
20
|
+
import { parseAppLink } from './link.ts';
|
|
21
|
+
import { launchUrl } from './urls.ts';
|
|
22
|
+
import { openWindow, openWindows, type WindowOptions } from './window.ts';
|
|
23
|
+
|
|
24
|
+
export type { OpenMode } from './instance.ts';
|
|
25
|
+
export { DEFAULT_WINDOW, type WindowOptions } from './window.ts';
|
|
26
|
+
|
|
27
|
+
/** What `onStart` gets to act on the running app. */
|
|
28
|
+
export interface StartContext {
|
|
29
|
+
/**
|
|
30
|
+
* A link that opens the app on `#<path>`, e.g. for a toast's `launch`: `<id>://open/<path>` when
|
|
31
|
+
* `urlProtocol` is on in the Windows exe, else the local address with no secret in it (toast XML
|
|
32
|
+
* is stored on disk), which opens on the invalid-session message. Links that must work need
|
|
33
|
+
* `urlProtocol: true`.
|
|
34
|
+
*/
|
|
35
|
+
link(path: string): string;
|
|
36
|
+
/** Opens a window (or browser tab) like a new launch would, on `#<path>` when given. */
|
|
37
|
+
show(mode: OpenMode, path?: string): void;
|
|
38
|
+
quit(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TrayMenu {
|
|
42
|
+
/** Default "Open <app name>". */
|
|
43
|
+
openLabel?: string;
|
|
44
|
+
/** Default "Quit". */
|
|
45
|
+
quitLabel?: string;
|
|
46
|
+
/** Entries between Open and Quit; ids must not start with `__`. */
|
|
47
|
+
items?: { id: string; label: string; run(): void }[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RunOptions {
|
|
51
|
+
app: AppIdentity;
|
|
52
|
+
/** The app's API; as a function it gets the start context, called before the server serves. */
|
|
53
|
+
routes: Routes | ((ctx: StartContext) => Routes);
|
|
54
|
+
/** WebSocket routes (see `startServer`); as a function it gets the start context, like `routes`. */
|
|
55
|
+
sockets?: SocketRoutes | ((ctx: StartContext) => SocketRoutes);
|
|
56
|
+
/** Routes outside the session token check (see `startServer`); as a function it gets the start context, like `routes`. */
|
|
57
|
+
unauthenticated?: Routes | ((ctx: StartContext) => Routes);
|
|
58
|
+
/** Awaited first on every exit path (window close, tray Quit, `/api/quit`, idle, signals); errors are logged and ignored. */
|
|
59
|
+
onQuit?: () => void | Promise<void>;
|
|
60
|
+
/** Built UI folder, served when running from source; the exe serves its embedded copy instead. */
|
|
61
|
+
webDir: string;
|
|
62
|
+
/** Rewrites `index.html` before it is served, e.g. `themeHead(settings)` for the saved colours. */
|
|
63
|
+
transformIndex?: IndexTransform;
|
|
64
|
+
window?: WindowOptions;
|
|
65
|
+
/**
|
|
66
|
+
* Registers `<app.id>://` on Windows (exe only), so `<id>://open/<path>` links open the app;
|
|
67
|
+
* `app.id` must then be a valid URL scheme.
|
|
68
|
+
*/
|
|
69
|
+
urlProtocol?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* A notification-area icon (Windows, not in --dev) with Open, the app's entries and Quit. The
|
|
72
|
+
* app then keeps running when its last window closes, and only exits when idle with --idle-exit.
|
|
73
|
+
*/
|
|
74
|
+
tray?: boolean | TrayMenu;
|
|
75
|
+
/** Runs once the server listens; not in a launch that hands off to a running instance. */
|
|
76
|
+
onStart?: (ctx: StartContext) => void | Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Starts the local server, then opens the UI in a native window (default), an Edge/Chrome app
|
|
81
|
+
* window or the browser. Outside --dev only one instance runs: a second launch makes the running
|
|
82
|
+
* one open a new window (or browser tab) and exits. Run `<app> --help` for the flags.
|
|
83
|
+
*/
|
|
84
|
+
export async function runApp(opts: RunOptions): Promise<void> {
|
|
85
|
+
const { app } = opts;
|
|
86
|
+
const flags = parseFlags(process.argv.slice(2));
|
|
87
|
+
if ('error' in flags) {
|
|
88
|
+
console.error(`${flags.error}\n\n${helpText(app)}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
if (flags.help) {
|
|
92
|
+
console.log(helpText(app));
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
if (flags.dev && isCompiledExe()) {
|
|
96
|
+
console.error('--dev only works from source.');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (opts.urlProtocol && !isUrlScheme(app.id)) {
|
|
101
|
+
throw new Error(`urlProtocol needs an app id that is a valid URL scheme, not "${app.id}".`);
|
|
102
|
+
}
|
|
103
|
+
const trayMenu = opts.tray === true ? {} : opts.tray || null;
|
|
104
|
+
const reserved = trayMenu?.items?.find((item) => item.id.startsWith('__'));
|
|
105
|
+
if (reserved) throw new Error(`Tray entry ids must not start with "__", got "${reserved.id}".`);
|
|
106
|
+
const { dev, openUrl, idleExitMinutes: idleMinutes } = flags;
|
|
107
|
+
// A link that doesn't parse still opens the app, just without a path.
|
|
108
|
+
const startPath = openUrl === undefined ? undefined : (parseAppLink(openUrl, app.id) ?? undefined);
|
|
109
|
+
let mode: OpenMode = flags.mode;
|
|
110
|
+
const browser = mode === 'app' ? findAppBrowser() : null;
|
|
111
|
+
if (mode === 'app' && !browser) {
|
|
112
|
+
console.warn('No Edge or Chrome found for --app; using the default browser.');
|
|
113
|
+
mode = 'browser';
|
|
114
|
+
}
|
|
115
|
+
const port = flags.port ?? (dev ? DEV_PORT : 0);
|
|
116
|
+
const token = randomBytes(24).toString('hex');
|
|
117
|
+
// Launch URLs carry one-time codes, never the token; the dev code stays valid so reloads work.
|
|
118
|
+
const codes = launchCodes({ reusable: dev });
|
|
119
|
+
const embedded = embeddedWebAssets();
|
|
120
|
+
const instanceFile = join(appHome(app.id), 'instance.json');
|
|
121
|
+
|
|
122
|
+
const alreadyRunning = (running: true | string) => {
|
|
123
|
+
if (running === true) console.log(`${app.name} is already running; opened a new ${mode === 'browser' ? 'browser tab' : 'window'}.`);
|
|
124
|
+
else console.log(`${app.name} is already running at ${running}`);
|
|
125
|
+
process.exit(0);
|
|
126
|
+
};
|
|
127
|
+
// Another launch holds the start-up lock: wait until it answers as the instance.
|
|
128
|
+
const waitForOther = async () => {
|
|
129
|
+
const deadline = Date.now() + 10_000;
|
|
130
|
+
while (Date.now() < deadline) {
|
|
131
|
+
await Bun.sleep(250);
|
|
132
|
+
const running = await handOff(instanceFile, mode, startPath);
|
|
133
|
+
if (running) alreadyRunning(running);
|
|
134
|
+
}
|
|
135
|
+
console.error(`Another ${app.name} launch is starting but doesn't answer. Try again.`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
};
|
|
138
|
+
if (!dev) {
|
|
139
|
+
const lockFile = join(appHome(app.id), 'instance.lock');
|
|
140
|
+
// Started by the previous exe after it swapped itself out: wait until that one has exited.
|
|
141
|
+
if (flags.afterUpdate && isCompiledExe()) await removeOldExe(process.execPath);
|
|
142
|
+
else {
|
|
143
|
+
const running = await handOff(instanceFile, mode, startPath);
|
|
144
|
+
if (running) alreadyRunning(running);
|
|
145
|
+
}
|
|
146
|
+
if (acquireLock(lockFile) === 'held') await waitForOther();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Where the UI is served: Vite in --dev, else this server. Set once the server listens.
|
|
150
|
+
let origin = '';
|
|
151
|
+
let idle: IdleWatch | null = null;
|
|
152
|
+
// Set while the tray icon starts or shows; `trayShown` once it does.
|
|
153
|
+
let tray: Tray | null = null;
|
|
154
|
+
let trayShown = false;
|
|
155
|
+
let closedWhileTrayStarts = false;
|
|
156
|
+
// Only the explicit --idle-exit watch, created at startup, survives the tray showing.
|
|
157
|
+
const enableIdleExit = (ms: number) => {
|
|
158
|
+
if (dev || idle || trayShown) return;
|
|
159
|
+
idle = createIdleWatch({ timeoutMs: ms, onIdle: () => void quit() });
|
|
160
|
+
};
|
|
161
|
+
const disableIdleExit = () => {
|
|
162
|
+
idle?.stop();
|
|
163
|
+
idle = null;
|
|
164
|
+
};
|
|
165
|
+
/** The single exit path. */
|
|
166
|
+
const quit = async (): Promise<void> => {
|
|
167
|
+
disableIdleExit();
|
|
168
|
+
try {
|
|
169
|
+
await opts.onQuit?.();
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.error(err);
|
|
172
|
+
}
|
|
173
|
+
closeAppWindows();
|
|
174
|
+
await Promise.all([server?.stop(true), tray?.close()]);
|
|
175
|
+
process.exit(0);
|
|
176
|
+
};
|
|
177
|
+
// Native windows and app windows both report here; the app ends once neither kind is open,
|
|
178
|
+
// unless it has a tray icon. While the icon starts, it waits to see whether it shows.
|
|
179
|
+
const onAllClosed = () => {
|
|
180
|
+
if (openWindows() > 0 || appWindowsOpen()) return;
|
|
181
|
+
if (!tray) void quit();
|
|
182
|
+
else if (!trayShown) closedWhileTrayStarts = true;
|
|
183
|
+
};
|
|
184
|
+
// WebView2 keeps its profile in %APPDATA%\<exe name>\: the webview library passes that
|
|
185
|
+
// folder explicitly, so WEBVIEW2_USER_DATA_FOLDER has no effect.
|
|
186
|
+
const openNative = (url: string) => {
|
|
187
|
+
openWindow(url, { title: app.name, debug: dev, stateFile: join(appHome(app.id), 'window.json'), ...opts.window }, onAllClosed);
|
|
188
|
+
};
|
|
189
|
+
const show = (m: OpenMode, path?: string): string => {
|
|
190
|
+
const url = launchUrl(origin, codes, path);
|
|
191
|
+
if (m === 'window') openNative(url);
|
|
192
|
+
else if (m === 'app') {
|
|
193
|
+
const exe = browser ?? findAppBrowser();
|
|
194
|
+
if (!exe) openExternal(url);
|
|
195
|
+
else {
|
|
196
|
+
openAppWindow(
|
|
197
|
+
url,
|
|
198
|
+
{
|
|
199
|
+
browser: exe,
|
|
200
|
+
profileDir: join(appHome(app.id), 'app-profile'),
|
|
201
|
+
window: opts.window,
|
|
202
|
+
// Windows of a browser process we don't watch never report closing: end when idle instead.
|
|
203
|
+
onUntracked: () => enableIdleExit(10 * 60_000),
|
|
204
|
+
onMainStarted: () => {
|
|
205
|
+
if (idleMinutes === undefined) disableIdleExit();
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
onAllClosed,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
} else if (m === 'browser') openExternal(url);
|
|
212
|
+
return url;
|
|
213
|
+
};
|
|
214
|
+
const instance = dev ? null : createInstance(instanceFile, show);
|
|
215
|
+
|
|
216
|
+
const linkViaProtocol = !!opts.urlProtocol && process.platform === 'win32' && isCompiledExe();
|
|
217
|
+
// Filled in once the server listens; routes only run after that.
|
|
218
|
+
let serverPort = 0;
|
|
219
|
+
const ctx: StartContext = {
|
|
220
|
+
link: (path) => (linkViaProtocol ? `${app.id}://open/${path}` : `http://127.0.0.1:${serverPort}/#${path}`),
|
|
221
|
+
show: (m, path) => void show(m, path),
|
|
222
|
+
quit,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const routes: Routes = {
|
|
226
|
+
...(typeof opts.routes === 'function' ? opts.routes(ctx) : opts.routes),
|
|
227
|
+
'POST /api/quit': () => {
|
|
228
|
+
setTimeout(() => void quit(), 200);
|
|
229
|
+
return { ok: true };
|
|
230
|
+
},
|
|
231
|
+
'POST /api/alive': () => ({ ok: true }),
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const sockets = typeof opts.sockets === 'function' ? opts.sockets(ctx) : opts.sockets;
|
|
235
|
+
const unauthenticated: Routes = { ...instance?.routes, ...(typeof opts.unauthenticated === 'function' ? opts.unauthenticated(ctx) : opts.unauthenticated) };
|
|
236
|
+
|
|
237
|
+
let server: ReturnType<typeof startServer> | undefined;
|
|
238
|
+
try {
|
|
239
|
+
server = startServer({
|
|
240
|
+
routes,
|
|
241
|
+
sockets,
|
|
242
|
+
unauthenticated,
|
|
243
|
+
token,
|
|
244
|
+
codes,
|
|
245
|
+
devOrigins: dev ? [`http://localhost:${VITE_PORT}`, `http://127.0.0.1:${VITE_PORT}`] : undefined,
|
|
246
|
+
port,
|
|
247
|
+
web: { ...(embedded ? { files: embedded } : { dir: opts.webDir }), transformIndex: opts.transformIndex },
|
|
248
|
+
onActivity: () => idle?.touch(),
|
|
249
|
+
});
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if ((err as { code?: string }).code === 'EADDRINUSE') console.error(`Port ${port} is already in use. Pass --port with another port.`);
|
|
252
|
+
else console.error(err);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
serverPort = server.port!;
|
|
257
|
+
origin = dev ? `http://localhost:${VITE_PORT}` : `http://127.0.0.1:${serverPort}`;
|
|
258
|
+
instance?.publish(serverPort);
|
|
259
|
+
|
|
260
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => void quit());
|
|
261
|
+
// The exe in browser mode has no window to close, so it ends once no tab has called for a while.
|
|
262
|
+
if (idleMinutes !== undefined) enableIdleExit(idleMinutes * 60_000);
|
|
263
|
+
else if (mode === 'browser' && isCompiledExe()) enableIdleExit(10 * 60_000);
|
|
264
|
+
|
|
265
|
+
if (opts.urlProtocol) {
|
|
266
|
+
void createUrlProtocol({ scheme: app.id, name: app.name }).fixUp().catch((err) => console.error(err));
|
|
267
|
+
}
|
|
268
|
+
if (trayMenu && !dev) {
|
|
269
|
+
tray = startTray({
|
|
270
|
+
name: app.name,
|
|
271
|
+
menu: trayMenu,
|
|
272
|
+
open: () => void show(mode === 'none' ? 'window' : mode),
|
|
273
|
+
quit: () => void quit(),
|
|
274
|
+
onShown: () => {
|
|
275
|
+
trayShown = true;
|
|
276
|
+
if (idleMinutes === undefined) disableIdleExit();
|
|
277
|
+
},
|
|
278
|
+
onFailed: (err) => {
|
|
279
|
+
console.warn(`No tray icon: ${err.message}`);
|
|
280
|
+
tray = null;
|
|
281
|
+
if (closedWhileTrayStarts) onAllClosed();
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
void Promise.resolve(opts.onStart?.(ctx)).catch((err) => console.error(err));
|
|
286
|
+
|
|
287
|
+
// Prints the URL the first open used: one-time outside --dev, so with --no-open it is still unused.
|
|
288
|
+
const announce = (url: string) => {
|
|
289
|
+
if (mode !== 'window') console.log(`\n ${app.name} running at ${url}\n Press Ctrl+C to stop.\n`);
|
|
290
|
+
};
|
|
291
|
+
// `deckbuilder dev` starts Vite only once this server answers; don't load the page before Vite does.
|
|
292
|
+
if (dev && (mode === 'window' || mode === 'app')) void waitForUrl(origin, { timeoutMs: 30_000 }).then(() => announce(show(mode, startPath)));
|
|
293
|
+
else announce(show(mode, startPath));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Shows the tray icon with Open, the app's entries and Quit; null where there is no tray. */
|
|
297
|
+
function startTray(o: {
|
|
298
|
+
name: string;
|
|
299
|
+
menu: TrayMenu;
|
|
300
|
+
open: () => void;
|
|
301
|
+
quit: () => void;
|
|
302
|
+
onShown: () => void;
|
|
303
|
+
onFailed: (err: Error) => void;
|
|
304
|
+
}): Tray | null {
|
|
305
|
+
const entries = [
|
|
306
|
+
{ id: '__open', label: o.menu.openLabel ?? `Open ${o.name}`, run: o.open },
|
|
307
|
+
...(o.menu.items ?? []),
|
|
308
|
+
{ id: '__quit', label: o.menu.quitLabel ?? 'Quit', run: o.quit },
|
|
309
|
+
];
|
|
310
|
+
const tray = createTray({
|
|
311
|
+
tooltip: o.name,
|
|
312
|
+
items: entries.map(({ id, label }) => ({ id, label })),
|
|
313
|
+
onSelect: (id) => entries.find((entry) => entry.id === id)?.run(),
|
|
314
|
+
});
|
|
315
|
+
if (!tray.supported) return null;
|
|
316
|
+
tray.ready.then(o.onShown, o.onFailed);
|
|
317
|
+
return tray;
|
|
318
|
+
}
|
package/src/instance.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { linkSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import type { Routes } from '@deckbuilderkit/server-core';
|
|
5
|
+
import { isSafePath } from './link.ts';
|
|
6
|
+
|
|
7
|
+
// Single instance: the running app publishes its port and a secret in instance.json (in the app
|
|
8
|
+
// data folder). A second launch asks it to open what was requested and exits. The secret only
|
|
9
|
+
// unlocks this one route, not the API: its response carries no session token, at most a launch URL
|
|
10
|
+
// with a one-time code.
|
|
11
|
+
|
|
12
|
+
export const INSTANCE_HEADER = 'x-deckbuilder-instance';
|
|
13
|
+
export const INSTANCE_PATH = '/api/instance/open';
|
|
14
|
+
export const INSTANCE_ROUTE = `POST ${INSTANCE_PATH}`;
|
|
15
|
+
|
|
16
|
+
/** What a launch asks for: a native window, an Edge/Chrome app window, a browser tab, or nothing (`--no-open`). */
|
|
17
|
+
export type OpenMode = 'window' | 'app' | 'browser' | 'none';
|
|
18
|
+
|
|
19
|
+
interface InstanceInfo {
|
|
20
|
+
pid: number;
|
|
21
|
+
port: number;
|
|
22
|
+
secret: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readInfo(file: string): InstanceInfo | null {
|
|
26
|
+
try {
|
|
27
|
+
const info = JSON.parse(readFileSync(file, 'utf8')) as Partial<InstanceInfo>;
|
|
28
|
+
return typeof info.port === 'number' && typeof info.secret === 'string' && typeof info.pid === 'number' ? (info as InstanceInfo) : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Asks the instance published in `file` to open `mode`, on `path` when given. Resolves true once it
|
|
36
|
+
* opened it, a fresh launch URL for mode 'none', or null when none answers (no file, or a stale one
|
|
37
|
+
* left by a killed process).
|
|
38
|
+
*/
|
|
39
|
+
export async function handOff(file: string, mode: OpenMode, path?: string, fetchFn: typeof fetch = fetch): Promise<true | string | null> {
|
|
40
|
+
const info = readInfo(file);
|
|
41
|
+
if (!info) return null;
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetchFn(`http://127.0.0.1:${info.port}${INSTANCE_PATH}`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { [INSTANCE_HEADER]: info.secret, 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ mode, path }),
|
|
47
|
+
signal: AbortSignal.timeout(3000),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) return null;
|
|
50
|
+
const body = (await res.json()) as { url?: unknown; ok?: unknown };
|
|
51
|
+
if (typeof body.url === 'string') return body.url;
|
|
52
|
+
return body.ok === true ? true : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Instance {
|
|
59
|
+
/** Unauthenticated route for the server; it checks the secret itself. */
|
|
60
|
+
routes: Routes;
|
|
61
|
+
/** Writes instance.json once the server listens; it is removed again on exit. */
|
|
62
|
+
publish(port: number): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `open` opens what a later launch asked for and returns the launch URL it used, if any. Only mode
|
|
67
|
+
* 'none' sends it back: that launch wants an address to print.
|
|
68
|
+
*/
|
|
69
|
+
export function createInstance(file: string, open: (mode: OpenMode, path?: string) => string | null): Instance {
|
|
70
|
+
const secret = Buffer.from(randomBytes(24).toString('hex'));
|
|
71
|
+
return {
|
|
72
|
+
routes: {
|
|
73
|
+
[INSTANCE_ROUTE]: async ({ req, json }) => {
|
|
74
|
+
const given = Buffer.from(req.headers.get(INSTANCE_HEADER) ?? '');
|
|
75
|
+
if (given.length !== secret.length || !timingSafeEqual(given, secret)) return new Response('Forbidden', { status: 403 });
|
|
76
|
+
const { mode, path } = await json<{ mode?: unknown; path?: unknown }>();
|
|
77
|
+
const safe = typeof path === 'string' && isSafePath(path) ? path : undefined;
|
|
78
|
+
const m = mode === 'app' || mode === 'browser' || mode === 'none' ? mode : 'window';
|
|
79
|
+
const url = open(m, safe);
|
|
80
|
+
return m === 'none' && url !== null ? { url } : { ok: true };
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
publish(port) {
|
|
84
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
85
|
+
writeFileSync(file, JSON.stringify({ pid: process.pid, port, secret: secret.toString() }), { mode: 0o600 });
|
|
86
|
+
process.on('exit', () => {
|
|
87
|
+
// A later instance may have taken the file over (e.g. after an update); leave its file.
|
|
88
|
+
if (readInfo(file)?.pid === process.pid) rmSync(file, { force: true });
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function lockPid(file: string): number | null {
|
|
95
|
+
try {
|
|
96
|
+
const pid = Number(readFileSync(file, 'utf8').trim());
|
|
97
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function alive(pid: number): boolean {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0);
|
|
106
|
+
return true;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// EPERM: the process exists but belongs to someone else.
|
|
109
|
+
return (err as { code?: string }).code === 'EPERM';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A lock older than this whose pid isn't the published instance is stale, whatever the pid. */
|
|
114
|
+
const LOCK_MAX_AGE_MS = 60_000;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A lock is stale when its process is gone, or when it is old and no published instance owns it:
|
|
118
|
+
* pids get reused, so a dead launch's lock can name a live, unrelated process.
|
|
119
|
+
*/
|
|
120
|
+
function staleLock(file: string, pid: number | null, now: number, instanceFile: string): boolean {
|
|
121
|
+
if (pid === null || pid === process.pid || !alive(pid)) return true;
|
|
122
|
+
let mtime: number;
|
|
123
|
+
try {
|
|
124
|
+
mtime = statSync(file).mtimeMs;
|
|
125
|
+
} catch {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return now - mtime > LOCK_MAX_AGE_MS && readInfo(instanceFile)?.pid !== pid;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Takes the start-up lock that keeps two launches from both becoming the instance. 'held' means
|
|
133
|
+
* another live launch has it; a stale lock (see `staleLock`) is taken over. Released on exit.
|
|
134
|
+
* `instanceFile` defaults to instance.json next to the lock.
|
|
135
|
+
*/
|
|
136
|
+
export function acquireLock(file: string, opts: { now?: () => number; instanceFile?: string } = {}): 'acquired' | 'held' {
|
|
137
|
+
const now = opts.now ?? Date.now;
|
|
138
|
+
const instanceFile = opts.instanceFile ?? join(dirname(file), 'instance.json');
|
|
139
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
140
|
+
// Link a complete temp file into place: the lock never exists without its pid.
|
|
141
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
142
|
+
writeFileSync(tmp, String(process.pid));
|
|
143
|
+
try {
|
|
144
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
145
|
+
try {
|
|
146
|
+
linkSync(tmp, file);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if ((err as { code?: string }).code !== 'EEXIST') throw err;
|
|
149
|
+
if (!staleLock(file, lockPid(file), now(), instanceFile)) return 'held';
|
|
150
|
+
rmSync(file, { force: true });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Two launches that both found the same stale lock: the later one removed ours. Wait for
|
|
154
|
+
// such a launch to finish its link, then trust only what is on disk.
|
|
155
|
+
Bun.sleepSync(100);
|
|
156
|
+
if (lockPid(file) !== process.pid) return 'held';
|
|
157
|
+
process.on('exit', () => {
|
|
158
|
+
if (lockPid(file) === process.pid) rmSync(file, { force: true });
|
|
159
|
+
});
|
|
160
|
+
return 'acquired';
|
|
161
|
+
}
|
|
162
|
+
return 'held';
|
|
163
|
+
} finally {
|
|
164
|
+
rmSync(tmp, { force: true });
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Deep links: `<scheme>://open/<path>` opens the app on `#<path>`.
|
|
2
|
+
|
|
3
|
+
/** A path that is safe to put after `#` in the UI address. */
|
|
4
|
+
export function isSafePath(path: string): boolean {
|
|
5
|
+
return /^[A-Za-z0-9_\-./]*$/.test(path) && !path.includes('..');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** The path of a `<scheme>://open/<path>` link, or null when it is not one or the path is unsafe. */
|
|
9
|
+
export function parseAppLink(uri: string, scheme: string): string | null {
|
|
10
|
+
const prefix = `${scheme}://open`;
|
|
11
|
+
if (uri.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return null;
|
|
12
|
+
const rest = uri.slice(prefix.length);
|
|
13
|
+
if (rest !== '' && !rest.startsWith('/')) return null;
|
|
14
|
+
const path = rest.replace(/^\//, '').replace(/\/$/, '');
|
|
15
|
+
return isSafePath(path) ? path : null;
|
|
16
|
+
}
|
package/src/urls.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { CODE_PARAM, type LaunchCodes } from '@deckbuilderkit/server-core';
|
|
2
|
+
|
|
3
|
+
/** A URL that opens the UI at `origin`, on `#<path>` when given, with a fresh launch code. */
|
|
4
|
+
export function launchUrl(origin: string, codes: LaunchCodes, path?: string): string {
|
|
5
|
+
const url = `${origin}/?${CODE_PARAM}=${codes.issue()}`;
|
|
6
|
+
return path ? `${url}#${path}` : url;
|
|
7
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// webview-bun ships TypeScript source, not .d.ts, so skipLibCheck doesn't cover it and its
|
|
2
|
+
// source fails our strict settings. tsconfig.json maps the import here; bun still runs the real package.
|
|
3
|
+
// Only the parts the shell uses are declared.
|
|
4
|
+
export enum SizeHint {
|
|
5
|
+
NONE = 0,
|
|
6
|
+
MIN = 1,
|
|
7
|
+
MAX = 2,
|
|
8
|
+
FIXED = 3,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Size {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
hint: SizeHint;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class Webview {
|
|
18
|
+
constructor(debug?: boolean, size?: Size);
|
|
19
|
+
/** Null when the native webview could not be created. */
|
|
20
|
+
get unsafeHandle(): unknown;
|
|
21
|
+
/** The native window: an HWND on Windows. */
|
|
22
|
+
get unsafeWindowHandle(): import('bun:ffi').Pointer | null;
|
|
23
|
+
set title(title: string);
|
|
24
|
+
set size(size: Size);
|
|
25
|
+
navigate(url: string): void;
|
|
26
|
+
init(source: string): void;
|
|
27
|
+
eval(source: string): void;
|
|
28
|
+
bind(name: string, callback: (...args: any[]) => any): void;
|
|
29
|
+
run(): void;
|
|
30
|
+
destroy(): void;
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The window's own zoom (what Ctrl+wheel used to change), set through WebView2's ICoreWebView2Controller.
|
|
2
|
+
// webview-bun 2.4.0 doesn't bind webview_get_native_handle, so the library is opened a second time for it.
|
|
3
|
+
import { CFunction, dlopen, FFIType, read, type Pointer } from 'bun:ffi';
|
|
4
|
+
// @ts-expect-error a native library imported as a file path, as webview-bun does
|
|
5
|
+
import bundledLib from 'webview-bun/build/libwebview.dll' with { type: 'file' };
|
|
6
|
+
|
|
7
|
+
const BROWSER_CONTROLLER = 2;
|
|
8
|
+
/** ICoreWebView2Controller vtable: IUnknown (3), get/put_IsVisible, get/put_Bounds, get_ZoomFactor, put_ZoomFactor. */
|
|
9
|
+
const PUT_ZOOM_FACTOR = 8;
|
|
10
|
+
|
|
11
|
+
/** Sets the zoom of the webview `handle` (webview-bun's `unsafeHandle`); call it on the window's thread. Windows only. */
|
|
12
|
+
export function setWebviewZoom(handle: Pointer, factor: number): void {
|
|
13
|
+
if (process.platform !== 'win32' || !Number.isFinite(factor) || factor <= 0) return;
|
|
14
|
+
const lib = dlopen((process.env.WEBVIEW_PATH as string | undefined) ?? (bundledLib as string), {
|
|
15
|
+
webview_get_native_handle: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.ptr },
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
const controller = lib.symbols.webview_get_native_handle(handle, BROWSER_CONTROLLER);
|
|
19
|
+
if (!controller) return;
|
|
20
|
+
const vtable = read.ptr(controller, 0) as Pointer;
|
|
21
|
+
const putZoomFactor = CFunction({ ptr: read.ptr(vtable, PUT_ZOOM_FACTOR * 8) as Pointer, args: [FFIType.ptr, FFIType.f64], returns: FFIType.i32 });
|
|
22
|
+
putZoomFactor(controller, factor);
|
|
23
|
+
} finally {
|
|
24
|
+
lib.close();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// One native window per Worker: Webview.run() blocks this thread until the window closes.
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { getWindowPlacement, openExternal, parsePlacement, setWindowIconFromExe, setWindowPlacement, showWindow, type WindowPlacement } from '@deckbuilderkit/platform';
|
|
5
|
+
import type { Pointer } from 'bun:ffi';
|
|
6
|
+
import { SizeHint, Webview } from 'webview-bun';
|
|
7
|
+
import { setWebviewZoom } from './webview-zoom.ts';
|
|
8
|
+
import type { WindowRequest } from './window.ts';
|
|
9
|
+
|
|
10
|
+
declare const self: Worker;
|
|
11
|
+
|
|
12
|
+
function readPlacement(file: string): WindowPlacement | undefined {
|
|
13
|
+
try {
|
|
14
|
+
return parsePlacement(JSON.parse(readFileSync(file, 'utf8')));
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Runs before every page load. Tells the UI it is in a window, sends links to other origins to
|
|
21
|
+
// the default browser instead of navigating the app window away, and stops Ctrl+wheel and
|
|
22
|
+
// Ctrl +/-/0 from zooming the whole window (the zoom is a setting; the page still gets the events).
|
|
23
|
+
const INIT = `
|
|
24
|
+
window.__DECKBUILDER_SHELL__ = 'window';
|
|
25
|
+
(() => {
|
|
26
|
+
addEventListener('wheel', (e) => { if (e.ctrlKey) e.preventDefault(); }, { passive: false, capture: true });
|
|
27
|
+
const zoomKeys = ['Equal', 'Minus', 'Digit0', 'NumpadAdd', 'NumpadSubtract', 'Numpad0'];
|
|
28
|
+
addEventListener('keydown', (e) => { if (e.ctrlKey && zoomKeys.includes(e.code)) e.preventDefault(); }, { capture: true });
|
|
29
|
+
const external = (href) => {
|
|
30
|
+
try { return new URL(href, location.href).origin !== location.origin; } catch { return false; }
|
|
31
|
+
};
|
|
32
|
+
document.addEventListener('click', (e) => {
|
|
33
|
+
// getAttribute, not a.href: an SVG link's href is an SVGAnimatedString, not a URL.
|
|
34
|
+
const a = e.target instanceof Element ? e.target.closest('a') : null;
|
|
35
|
+
const href = a && (a.getAttribute('href') ?? a.getAttribute('xlink:href'));
|
|
36
|
+
if (href == null || e.defaultPrevented || !external(href)) return;
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
window.__deckbuilderOpenExternal(new URL(href, location.href).href);
|
|
39
|
+
});
|
|
40
|
+
const saveBounds = () => { if (window.__deckbuilderSaveBounds) window.__deckbuilderSaveBounds(); };
|
|
41
|
+
setInterval(saveBounds, 1000);
|
|
42
|
+
addEventListener('beforeunload', saveBounds);
|
|
43
|
+
const open = window.open;
|
|
44
|
+
window.open = (url, ...rest) => {
|
|
45
|
+
if (url && external(String(url))) { window.__deckbuilderOpenExternal(String(url)); return null; }
|
|
46
|
+
return open.call(window, url, ...rest);
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
self.onmessage = (e: MessageEvent<WindowRequest>) => {
|
|
52
|
+
// The window shows #121212 until the page paints (WEBVIEW2_DEFAULT_BACKGROUND_COLOR has no effect
|
|
53
|
+
// with this webview library); the palette inlined into index.html (themeHead) carries the first paint.
|
|
54
|
+
const { url, title, width, height, debug, stateFile } = e.data;
|
|
55
|
+
const webview = new Webview(debug, { width, height, hint: SizeHint.NONE });
|
|
56
|
+
if (!webview.unsafeHandle) {
|
|
57
|
+
// WebView2 failed to start, e.g. its profile folder is still locked by msedgewebview2.exe
|
|
58
|
+
// processes of a previous run that was killed.
|
|
59
|
+
self.postMessage({ error: 'Could not create the window (WebView2 failed to start).' });
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
// Started through `bun run`, the process starts hidden and the library's own show is swallowed.
|
|
63
|
+
const hwnd = webview.unsafeWindowHandle;
|
|
64
|
+
showWindow(hwnd);
|
|
65
|
+
setWindowIconFromExe(hwnd);
|
|
66
|
+
if (stateFile) {
|
|
67
|
+
const saved = readPlacement(stateFile);
|
|
68
|
+
if (saved) setWindowPlacement(hwnd, saved);
|
|
69
|
+
// The window is gone once run() returns, so the page reports in while it lives: every second and on unload.
|
|
70
|
+
let last = JSON.stringify(saved ?? null);
|
|
71
|
+
webview.bind('__deckbuilderSaveBounds', () => {
|
|
72
|
+
const now = getWindowPlacement(hwnd);
|
|
73
|
+
const text = JSON.stringify(now ?? null);
|
|
74
|
+
if (!now || text === last) return;
|
|
75
|
+
last = text;
|
|
76
|
+
try {
|
|
77
|
+
mkdirSync(dirname(stateFile), { recursive: true });
|
|
78
|
+
writeFileSync(stateFile, text);
|
|
79
|
+
} catch {
|
|
80
|
+
// losing the position is harmless
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
webview.title = title;
|
|
85
|
+
webview.bind('__deckbuilderOpenExternal', (href: string) => openExternal(href));
|
|
86
|
+
webview.bind('__deckbuilderSetZoom', (factor: number) => setWebviewZoom(webview.unsafeHandle as Pointer, factor));
|
|
87
|
+
webview.init(INIT);
|
|
88
|
+
webview.navigate(url);
|
|
89
|
+
webview.run();
|
|
90
|
+
process.exit(0);
|
|
91
|
+
};
|
package/src/window.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { showError } from '@deckbuilderkit/platform';
|
|
2
|
+
|
|
3
|
+
export interface WindowOptions {
|
|
4
|
+
title?: string;
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
/** Enables the WebView2 dev tools (F12, right-click → Inspect). */
|
|
8
|
+
debug?: boolean;
|
|
9
|
+
/** JSON file where the window's position, size and maximized state are kept between runs. */
|
|
10
|
+
stateFile?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Message the window worker receives. */
|
|
14
|
+
export interface WindowRequest {
|
|
15
|
+
url: string;
|
|
16
|
+
title: string;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
debug: boolean;
|
|
20
|
+
stateFile?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Size of a new native or app window unless the app passes one. */
|
|
24
|
+
export const DEFAULT_WINDOW = { width: 1280, height: 820 };
|
|
25
|
+
|
|
26
|
+
let open = 0;
|
|
27
|
+
|
|
28
|
+
/** Native windows that are still open. */
|
|
29
|
+
export function openWindows(): number {
|
|
30
|
+
return open;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Opens a native window on `url`. Each window runs in its own Worker because Webview.run()
|
|
35
|
+
* blocks its thread; the server stays on the main thread. `onAllClosed` runs when the last one closes.
|
|
36
|
+
*/
|
|
37
|
+
export function openWindow(url: string, opts: WindowOptions, onAllClosed: () => void): void {
|
|
38
|
+
open++;
|
|
39
|
+
// In the exe this resolves next to the bundled entry, where `deckbuilder build` puts the worker entrypoint.
|
|
40
|
+
const worker = new Worker(new URL('./window-worker.ts', import.meta.url).href);
|
|
41
|
+
const request: WindowRequest = {
|
|
42
|
+
url,
|
|
43
|
+
title: opts.title ?? '',
|
|
44
|
+
width: opts.width ?? DEFAULT_WINDOW.width,
|
|
45
|
+
height: opts.height ?? DEFAULT_WINDOW.height,
|
|
46
|
+
debug: opts.debug ?? false,
|
|
47
|
+
stateFile: opts.stateFile,
|
|
48
|
+
};
|
|
49
|
+
worker.postMessage(request);
|
|
50
|
+
worker.addEventListener('message', (e: MessageEvent<{ error?: string }>) => {
|
|
51
|
+
if (e.data.error) showError(opts.title ?? 'Error', e.data.error);
|
|
52
|
+
});
|
|
53
|
+
worker.addEventListener('error', (e) => showError(opts.title ?? 'Error', `Window failed: ${e.message}`));
|
|
54
|
+
worker.addEventListener('close', () => {
|
|
55
|
+
if (--open === 0) onAllClosed();
|
|
56
|
+
});
|
|
57
|
+
}
|
package/README.md
DELETED