@deckbuilderkit/server-mods 0.0.0-stage → 0.1.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.
@@ -0,0 +1,82 @@
1
+ import { join } from 'node:path';
2
+ import { readJsonFile, writeJsonAtomic } from '@deckbuilderkit/platform';
3
+ import { isRecord, type Routes } from '@deckbuilderkit/server-core';
4
+ import { ICON_SET_IDS, LAYOUT_MARK_IDS, layoutById } from '@deckbuilderkit/server-core/layouts';
5
+ import { PALETTES, ZOOM_LEVELS, type ThemeKind, type ThemeSettings } from '@deckbuilderkit/server-core/palettes';
6
+
7
+ /** Settings every app gets; apps extend this with their own fields. */
8
+ export interface BaseSettings extends ThemeSettings {
9
+ /** UI language (e.g. "en", "pt-BR"); unset means "follow the browser". */
10
+ language?: string;
11
+ }
12
+
13
+ export interface SettingsOptions<T extends BaseSettings> {
14
+ /** Folder of settings.json, usually `appHome(APP.id)`. */
15
+ dir: string;
16
+ defaults: T;
17
+ /** Copies the valid app-specific fields of `patch` onto `next`; unknown or malformed fields are ignored. */
18
+ apply?: (patch: Record<string, unknown>, next: T) => void;
19
+ /** Runs after every save, with the new and the previous settings. */
20
+ onChange?: (next: T, prev: T) => void;
21
+ }
22
+
23
+ export interface SettingsStore<T extends BaseSettings> {
24
+ dir: string;
25
+ load(): Promise<T>;
26
+ save(patch: Record<string, unknown>): Promise<T>;
27
+ }
28
+
29
+ export function settingsStore<T extends BaseSettings>(opts: SettingsOptions<T>): SettingsStore<T> {
30
+ const file = join(opts.dir, 'settings.json');
31
+ let cache: T | null = null;
32
+
33
+ async function load(): Promise<T> {
34
+ if (cache) return cache;
35
+ const stored = await readJsonFile(file, isRecord);
36
+ cache = { ...opts.defaults, ...(stored as object | undefined) };
37
+ return cache;
38
+ }
39
+
40
+ async function save(patch: Record<string, unknown>): Promise<T> {
41
+ const prev = await load();
42
+ const next: T = { ...prev };
43
+ if (patch.theme === 'system' || patch.theme === 'dark' || patch.theme === 'light') next.theme = patch.theme;
44
+ applyPalette(patch.paletteDark, 'dark', next);
45
+ applyPalette(patch.paletteLight, 'light', next);
46
+ if (ZOOM_LEVELS.includes(patch.zoom as never)) next.zoom = patch.zoom as number;
47
+ if (patch.layout === '') delete next.layout;
48
+ else if (typeof patch.layout === 'string' && layoutById(patch.layout)) next.layout = patch.layout;
49
+ if (patch.icons === '') delete next.icons;
50
+ else if (typeof patch.icons === 'string' && ICON_SET_IDS.includes(patch.icons)) next.icons = patch.icons;
51
+ if (patch.mainMenu === '') delete next.mainMenu;
52
+ else if (patch.mainMenu === 'labels' || patch.mainMenu === 'icons') next.mainMenu = patch.mainMenu;
53
+ if (patch.dock === '') delete next.dock;
54
+ else if (patch.dock === 'left' || patch.dock === 'right') next.dock = patch.dock;
55
+ if (patch.layoutIcon === '') delete next.layoutIcon;
56
+ else if (typeof patch.layoutIcon === 'string' && LAYOUT_MARK_IDS.includes(patch.layoutIcon)) next.layoutIcon = patch.layoutIcon;
57
+ if (typeof patch.language === 'string' && /^[a-z]{2}(-[A-Z]{2})?$/.test(patch.language)) next.language = patch.language;
58
+ opts.apply?.(patch, next);
59
+ cache = next;
60
+ await writeJsonAtomic(file, next);
61
+ opts.onChange?.(next, prev);
62
+ return next;
63
+ }
64
+
65
+ return { dir: opts.dir, load, save };
66
+ }
67
+
68
+
69
+ /** Saves `value` as the `kind` palette when it is a palette id of that kind; an empty string clears it, anything else is ignored. */
70
+ function applyPalette(value: unknown, kind: ThemeKind, next: ThemeSettings): void {
71
+ const key = kind === 'dark' ? 'paletteDark' : 'paletteLight';
72
+ if (value === '') delete next[key];
73
+ else if (PALETTES.some((p) => p.id === value && p.kind === kind)) next[key] = value as string;
74
+ }
75
+
76
+ /** GET and PUT /api/settings. */
77
+ export function settingsRoutes<T extends BaseSettings>(store: SettingsStore<T>): Routes {
78
+ return {
79
+ 'GET /api/settings': () => store.load(),
80
+ 'PUT /api/settings': async ({ json }) => store.save(await json()),
81
+ };
82
+ }
package/src/sqlite.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { Database } from 'bun:sqlite';
2
+
3
+ /** What `/api/fs/sqlite` returns: the tables of a database file and one page of one table. */
4
+ export interface SqliteContent {
5
+ tables: { name: string; view: boolean }[];
6
+ table: string | null;
7
+ columns: string[];
8
+ /** Cells are numbers, strings, null or a short description of a blob. */
9
+ rows: (string | number | null)[][];
10
+ total: number;
11
+ offset: number;
12
+ }
13
+
14
+ export const SQLITE_PAGE = 200;
15
+
16
+ const cell = (v: unknown): string | number | null => {
17
+ if (v === null || v === undefined) return null;
18
+ if (typeof v === 'number' || typeof v === 'string') return v;
19
+ if (typeof v === 'bigint') return v.toString();
20
+ if (v instanceof Uint8Array) return `<blob ${v.length} bytes>`;
21
+ return String(v);
22
+ };
23
+
24
+ const quote = (name: string) => `"${name.replaceAll('"', '""')}"`;
25
+
26
+ /** Opens `file` read-only and reads one page of `table` (the first table when null). */
27
+ export function readSqlite(file: string, table: string | null, offset: number): SqliteContent {
28
+ const db = new Database(file, { readonly: true });
29
+ try {
30
+ const tables = db
31
+ .query<{ name: string; type: string }, []>("SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name")
32
+ .all()
33
+ .map((t) => ({ name: t.name, view: t.type === 'view' }));
34
+ const chosen = tables.find((t) => t.name === table) ?? tables[0];
35
+ if (!chosen) return { tables, table: null, columns: [], rows: [], total: 0, offset: 0 };
36
+ const target = quote(chosen.name);
37
+ const total = db.query<{ n: number }, []>(`SELECT count(*) AS n FROM ${target}`).get()?.n ?? 0;
38
+ const statement = db.query(`SELECT * FROM ${target} LIMIT ${SQLITE_PAGE} OFFSET ${Math.max(0, Math.floor(offset))}`);
39
+ const columns = statement.columnNames;
40
+ const rows = statement.values().map((row) => (row as unknown[]).map(cell));
41
+ return { tables, table: chosen.name, columns, rows, total, offset: Math.max(0, Math.floor(offset)) };
42
+ } finally {
43
+ db.close();
44
+ }
45
+ }
@@ -0,0 +1,36 @@
1
+ import { resolveLayout } from '@deckbuilderkit/server-core/layouts';
2
+ import { type Palette, paletteFor, TOKENS } from '@deckbuilderkit/server-core/palettes';
3
+ import type { BaseSettings, SettingsStore } from './settings.ts';
4
+
5
+ // Same specificity as the token blocks in styles.css; placed after the stylesheet link, so it wins.
6
+ const SELECTOR = ':root[data-theme]';
7
+
8
+ function block(palette: Palette): string {
9
+ const props = TOKENS.map((token) => `${token}:${palette.tokens[token]};`).join('');
10
+ return `${SELECTOR}{color-scheme:${palette.kind};${props}}`;
11
+ }
12
+
13
+ /** The saved palette as a `<style>` before `</head>`, so the first paint has its colours; for `theme: 'system'` a media query picks the side. */
14
+ export function themeHtml(html: string, settings: BaseSettings): string {
15
+ const end = html.indexOf('</head>');
16
+ if (end < 0) return html;
17
+ const layout = resolveLayout(settings.layout).id;
18
+ if (/<html\b[^>]*\bdata-layout=/.test(html)) {
19
+ html = html.replace(/(<html\b[^>]*\bdata-layout=)(["'])[^"']*\2/, `$1$2${layout}$2`);
20
+ } else {
21
+ html = html.replace(/<html\b([^>]*)>/, `<html$1 data-layout="${layout}">`);
22
+ }
23
+ let css: string;
24
+ if (settings.theme === 'system') {
25
+ css = `${block(paletteFor(settings, 'dark'))}@media (prefers-color-scheme: light){${block(paletteFor(settings, 'light'))}}`;
26
+ } else {
27
+ css = block(paletteFor(settings, settings.theme));
28
+ html = html.replace(/(<html\b[^>]*\bdata-theme=)(["'])[^"']*\2/, `$1$2${settings.theme}$2`);
29
+ }
30
+ return html.replace('</head>', `<style id="deckbuilder-theme">${css}</style>\n</head>`);
31
+ }
32
+
33
+ /** `RunOptions.transformIndex` that inlines the saved theme into `index.html`; `useTheme` takes over once the UI runs. */
34
+ export function themeHead(store: SettingsStore<BaseSettings>): (html: string) => Promise<string> {
35
+ return async (html) => themeHtml(html, await store.load());
36
+ }
package/src/update.ts ADDED
@@ -0,0 +1,435 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { copyFile, readFile, rename, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { isCompiledExe, startDetached, updatePaths } from '@deckbuilderkit/platform';
6
+ import {
7
+ AppError,
8
+ isNewerVersion,
9
+ MANIFEST,
10
+ MANIFEST_SIG,
11
+ parseManifest,
12
+ platformLabel,
13
+ type ReleaseFile,
14
+ type ReleaseManifest,
15
+ type Routes,
16
+ type UpdateState,
17
+ verifyManifest,
18
+ } from '@deckbuilderkit/server-core';
19
+
20
+ export const CHECK_INTERVAL_MS = 6 * 60 * 60_000;
21
+ /** After a failed check (off VPN, share down): try again sooner than the regular interval. */
22
+ export const RETRY_MS = 30 * 60_000;
23
+ /** A UNC path off VPN can hang for the SMB timeout; give up before that. */
24
+ const READ_TIMEOUT_MS = 15_000;
25
+ /** `POST /api/update/apply` answers before the process quits. */
26
+ const QUIT_DELAY_MS = 300;
27
+
28
+ /** File operations the updater needs; faked in tests. */
29
+ export interface UpdateFs {
30
+ readText(path: string): Promise<string>;
31
+ readBytes(path: string): Promise<Uint8Array>;
32
+ copy(from: string, to: string): Promise<void>;
33
+ sha256(path: string): Promise<string>;
34
+ rename(from: string, to: string): Promise<void>;
35
+ /** Succeeds when the file is already gone. */
36
+ remove(path: string): Promise<void>;
37
+ }
38
+
39
+ const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
40
+ Promise.race([p, new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timed out')), ms).unref())]);
41
+
42
+ const nodeFs: UpdateFs = {
43
+ readText: (path) => withTimeout(readFile(path, 'utf8'), READ_TIMEOUT_MS),
44
+ readBytes: async (path) => new Uint8Array(await withTimeout(readFile(path), READ_TIMEOUT_MS)),
45
+ copy: (from, to) => copyFile(from, to),
46
+ sha256: (path) =>
47
+ new Promise((resolve, reject) => {
48
+ const hash = createHash('sha256');
49
+ createReadStream(path)
50
+ .on('data', (chunk) => hash.update(chunk))
51
+ .on('error', reject)
52
+ .on('end', () => resolve(hash.digest('hex')));
53
+ }),
54
+ rename: (from, to) => rename(from, to),
55
+ remove: (path) => rm(path, { force: true }),
56
+ };
57
+
58
+ /** A refusal or failure to show the user; `code` is its message id (`error.<code>`). */
59
+ export class UpdateError extends AppError {
60
+ constructor(code: string, message: string, params?: Record<string, string | number>) {
61
+ super('invalid', code, message, { params });
62
+ }
63
+ }
64
+
65
+ /** The manifest couldn't be read (`unreachable`: off VPN, share down) or isn't a release. */
66
+ export class SourceError extends Error {
67
+ constructor(
68
+ readonly unreachable: boolean,
69
+ message: string,
70
+ ) {
71
+ super(message);
72
+ }
73
+ }
74
+
75
+ /** The release in a manifest's text; null when it isn't JSON or not a valid release. */
76
+ export function readManifest(text: string): ReleaseManifest | null {
77
+ try {
78
+ return parseManifest(JSON.parse(text));
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** What a source found, not yet trusted: the updater checks the signature, then parses. */
85
+ export interface SignedRelease {
86
+ /** The exact bytes of latest.json. */
87
+ bytes: Uint8Array;
88
+ /** latest.json.sig; null when the source has none. */
89
+ signature: string | null;
90
+ /** Copies `file` of this release to `to`. */
91
+ download(file: ReleaseFile, to: string): Promise<void>;
92
+ }
93
+
94
+ export interface Release {
95
+ manifest: ReleaseManifest;
96
+ download: SignedRelease['download'];
97
+ }
98
+
99
+ /** Where releases come from: a folder or GitHub releases. */
100
+ export interface UpdateSource {
101
+ /** Identifies the source: once it changes, what the previous one offered no longer applies. */
102
+ id: string;
103
+ /** Throws SourceError. */
104
+ latest(): Promise<SignedRelease>;
105
+ }
106
+
107
+ /**
108
+ * The release, once its signature checks out against the app's public key. Throws
109
+ * UpdateError('updateBadSignature') for a missing or wrong signature, SourceError when the signed
110
+ * manifest isn't a valid release.
111
+ */
112
+ export function verifiedRelease(signed: SignedRelease, publicKey: string): Release {
113
+ if (signed.signature === null || !verifyManifest(signed.bytes, signed.signature, publicKey)) {
114
+ throw new UpdateError('updateBadSignature', `${MANIFEST} isn't signed with this app's update key.`);
115
+ }
116
+ const manifest = readManifest(new TextDecoder().decode(signed.bytes));
117
+ if (!manifest) throw new SourceError(false, `${MANIFEST} is not a valid release`);
118
+ return { manifest, download: signed.download };
119
+ }
120
+
121
+ /**
122
+ * Releases in a folder (often a UNC share) holding latest.json, as `deckbuilder release --to`
123
+ * writes it; also looks in its `<subfolder>` (the app name), where `release --to <root>` puts them.
124
+ */
125
+ export function folderSource(path: string, opts: { subfolder?: string; fs?: UpdateFs } = {}): UpdateSource {
126
+ const fs = opts.fs ?? nodeFs;
127
+ return {
128
+ id: `folder:${path}`,
129
+ async latest() {
130
+ let unreachable = true;
131
+ for (const dir of opts.subfolder ? [path, join(path, opts.subfolder)] : [path]) {
132
+ let bytes: Uint8Array;
133
+ try {
134
+ bytes = await fs.readBytes(join(dir, MANIFEST));
135
+ } catch {
136
+ continue;
137
+ }
138
+ unreachable = false;
139
+ // Picks the folder holding a release; the updater trusts it only once the signature checks out.
140
+ if (!readManifest(new TextDecoder().decode(bytes))) continue;
141
+ const signature = await fs.readText(join(dir, MANIFEST_SIG)).catch(() => null);
142
+ return { bytes, signature, download: (file, to) => fs.copy(join(dir, file.file), to) };
143
+ }
144
+ throw new SourceError(unreachable, unreachable ? `Can't read ${MANIFEST} in ${path}` : `${MANIFEST} in ${path} is not a valid release`);
145
+ },
146
+ };
147
+ }
148
+
149
+ interface GithubAsset {
150
+ name: string;
151
+ url: string;
152
+ }
153
+
154
+ interface GithubRelease {
155
+ tag_name: string;
156
+ draft: boolean;
157
+ prerelease: boolean;
158
+ assets: GithubAsset[];
159
+ }
160
+
161
+ /**
162
+ * Releases of a GitHub repo: the newest published (not draft, not prerelease) one whose tag starts
163
+ * with `tagPrefix` and that carries a latest.json asset, as the release workflow uploads it.
164
+ */
165
+ export function githubSource(opts: { repo: string; tagPrefix: string; token?: string; fetch?: typeof fetch }): UpdateSource {
166
+ const doFetch = opts.fetch ?? fetch;
167
+ const headers = (accept: string, auth: boolean): Record<string, string> => ({
168
+ Accept: accept,
169
+ 'User-Agent': 'deckbuilder',
170
+ ...(auth && opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
171
+ });
172
+
173
+ /** Asset content: the API URL answers with a redirect to a storage host, which must not get the token. */
174
+ const assetRequest = async (asset: GithubAsset, timeoutMs?: number): Promise<Response> => {
175
+ const signal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
176
+ let res = await doFetch(asset.url, { headers: headers('application/octet-stream', true), redirect: 'manual', signal });
177
+ if (res.status >= 300 && res.status < 400) {
178
+ const location = res.headers.get('location');
179
+ if (!location) throw new SourceError(false, `GitHub redirected ${asset.name} without a location`);
180
+ res = await doFetch(location, { headers: { 'User-Agent': 'deckbuilder' }, signal });
181
+ }
182
+ if (!res.ok) throw new SourceError(false, `GitHub answered ${res.status} for ${asset.name}`);
183
+ return res;
184
+ };
185
+
186
+ const network = async <T>(what: string, fn: () => Promise<T>): Promise<T> => {
187
+ try {
188
+ return await fn();
189
+ } catch (e) {
190
+ if (e instanceof SourceError) throw e;
191
+ throw new SourceError(true, `Can't reach GitHub for ${what}: ${(e as Error).message}`);
192
+ }
193
+ };
194
+
195
+ return {
196
+ id: `github:${opts.repo}`,
197
+ async latest() {
198
+ const releases = await network('the release list', async () => {
199
+ const res = await doFetch(`https://api.github.com/repos/${opts.repo}/releases?per_page=20`, {
200
+ headers: headers('application/vnd.github+json', true),
201
+ signal: AbortSignal.timeout(READ_TIMEOUT_MS),
202
+ });
203
+ if (!res.ok) throw new SourceError(false, `GitHub answered ${res.status} for the releases of ${opts.repo}`);
204
+ return (await res.json()) as GithubRelease[];
205
+ });
206
+ const release = releases.find((r) => !r.draft && !r.prerelease && r.tag_name.startsWith(opts.tagPrefix));
207
+ if (!release) throw new SourceError(false, `${opts.repo} has no published release tagged ${opts.tagPrefix}*`);
208
+ const find = (name: string) => release.assets.find((a) => a.name === name);
209
+ const manifestAsset = find(MANIFEST);
210
+ if (!manifestAsset) throw new SourceError(false, `Release ${release.tag_name} has no ${MANIFEST}`);
211
+ const bytes = await network(MANIFEST, async () => new Uint8Array(await (await assetRequest(manifestAsset, READ_TIMEOUT_MS)).arrayBuffer()));
212
+ const sigAsset = find(MANIFEST_SIG);
213
+ const signature = sigAsset ? await network(MANIFEST_SIG, async () => (await assetRequest(sigAsset, READ_TIMEOUT_MS)).text()) : null;
214
+ return {
215
+ bytes,
216
+ signature,
217
+ async download(file, to) {
218
+ const asset = find(file.file);
219
+ if (!asset) throw new Error(`Release ${release.tag_name} has no ${file.file}`);
220
+ await Bun.write(to, await assetRequest(asset));
221
+ },
222
+ };
223
+ },
224
+ };
225
+ }
226
+
227
+ export interface Clock {
228
+ now(): number;
229
+ setTimeout(fn: () => void, ms: number): unknown;
230
+ clearTimeout(handle: unknown): void;
231
+ }
232
+
233
+ const systemClock: Clock = {
234
+ now: () => Date.now(),
235
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
236
+ clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
237
+ };
238
+
239
+ export interface UpdaterOptions {
240
+ version: string;
241
+ /** The app's update public key (updatePublicKey); null: this build can't trust any release. */
242
+ publicKey: string | null;
243
+ /** Read at every check; null means not configured. */
244
+ source(): UpdateSource | null | Promise<UpdateSource | null>;
245
+ execPath?: string;
246
+ /** False when running from source: checks work, apply refuses. */
247
+ compiled?: boolean;
248
+ /** Flags for the new exe, after `--after-update`; default: this process's flags. */
249
+ relaunchArgs?: string[];
250
+ /** Release file label to install; default: this machine (platformLabel). */
251
+ label?: string;
252
+ onChange?(state: UpdateState): void;
253
+ fs?: UpdateFs;
254
+ /** Starts the new exe detached from this process; rejects when it can't. */
255
+ start?: (argv: string[]) => Promise<void>;
256
+ clock?: Clock;
257
+ log?: (msg: string) => void;
258
+ }
259
+
260
+ export interface Updater {
261
+ /** First check, then every 6 h. */
262
+ start(): void;
263
+ /** Checks now; call it after the source setting changed. */
264
+ check(): Promise<UpdateState>;
265
+ /**
266
+ * Swaps the running exe for the released one and starts it; the caller then quits this
267
+ * process. Throws UpdateError with the current exe left in place.
268
+ */
269
+ apply(): Promise<{ version: string }>;
270
+ state(): UpdateState;
271
+ stop(): void;
272
+ }
273
+
274
+ export function createUpdater(opts: UpdaterOptions): Updater {
275
+ const fs = opts.fs ?? nodeFs;
276
+ const clock = opts.clock ?? systemClock;
277
+ const execPath = opts.execPath ?? process.execPath;
278
+ const compiled = opts.compiled ?? isCompiledExe(execPath);
279
+ const label = opts.label ?? platformLabel();
280
+ const log = opts.log ?? ((msg) => console.log(`update: ${msg}`));
281
+ const empty = { current: opts.version, canApply: compiled, available: false, version: null, notes: null, checkedAt: null };
282
+ let sourceId: string | null = null;
283
+ let state: UpdateState = { ...empty, status: 'not-configured', nextAt: null, source: null };
284
+ const withSource = (s: Omit<UpdateState, 'source'>): UpdateState => ({ ...s, source: sourceId });
285
+ let timer: unknown = null;
286
+ let inflight: Promise<UpdateState> | null = null;
287
+ let queued: Promise<UpdateState> | null = null;
288
+ let applying = false;
289
+
290
+ const publish = (next: Omit<UpdateState, 'source'>) => {
291
+ if (next.status !== state.status || next.message !== state.message) log(`check: ${next.status}${next.message ? ` (${next.message})` : ''}`);
292
+ state = withSource(next);
293
+ opts.onChange?.(state);
294
+ return state;
295
+ };
296
+
297
+ const schedule = (ms: number | null) => {
298
+ if (timer !== null) clock.clearTimeout(timer);
299
+ timer = ms === null ? null : clock.setTimeout(() => void check(), ms);
300
+ return ms === null ? null : clock.now() + ms;
301
+ };
302
+
303
+ const run = async (): Promise<UpdateState> => {
304
+ const source = await opts.source();
305
+ if (source?.id !== sourceId) {
306
+ // What the previous source offered no longer applies.
307
+ sourceId = source?.id ?? null;
308
+ state = { ...state, ...empty };
309
+ }
310
+ if (!source) return publish({ ...empty, status: 'not-configured', nextAt: schedule(null) });
311
+ if (opts.publicKey === null) {
312
+ return publish({ ...empty, canApply: false, status: 'not-configured', code: 'updateNoKey', message: 'No update key in this build', nextAt: schedule(null) });
313
+ }
314
+ try {
315
+ const { manifest } = verifiedRelease(await source.latest(), opts.publicKey);
316
+ const available = isNewerVersion(manifest.version, opts.version) && manifest.files[label] !== undefined;
317
+ return publish({
318
+ ...empty,
319
+ status: 'ok',
320
+ available,
321
+ version: manifest.version,
322
+ notes: manifest.notes,
323
+ checkedAt: clock.now(),
324
+ nextAt: schedule(CHECK_INTERVAL_MS),
325
+ });
326
+ } catch (e) {
327
+ // A tampered manifest must not keep advertising an update.
328
+ if (e instanceof UpdateError) return publish({ ...empty, status: 'error', code: e.code, message: e.message, nextAt: schedule(RETRY_MS) });
329
+ // The last good result stays: a share that's briefly away shouldn't hide an update.
330
+ const status = e instanceof SourceError && e.unreachable ? 'offline' : 'error';
331
+ return publish({ ...state, status, code: undefined, message: (e as Error).message, nextAt: schedule(RETRY_MS) });
332
+ }
333
+ };
334
+
335
+ // A check asked for during another (e.g. the source just changed) runs again after it.
336
+ const check = (): Promise<UpdateState> => {
337
+ if (inflight) {
338
+ queued ??= inflight.then(() => {
339
+ queued = null;
340
+ return check();
341
+ });
342
+ return queued;
343
+ }
344
+ inflight = run().finally(() => {
345
+ inflight = null;
346
+ });
347
+ return inflight;
348
+ };
349
+
350
+ const apply = async (): Promise<{ version: string }> => {
351
+ if (!compiled) throw new UpdateError('updateFromSource', 'Running from source: only the built exe can update itself.');
352
+ const source = await opts.source();
353
+ if (!source) throw new UpdateError('updateNotConfigured', 'No update source is configured.');
354
+ if (opts.publicKey === null) throw new UpdateError('updateNoKey', 'This build has no update key.');
355
+ if (applying) throw new UpdateError('updateBusy', 'An update is already running.');
356
+ applying = true;
357
+ try {
358
+ let release: Release;
359
+ try {
360
+ release = verifiedRelease(await source.latest(), opts.publicKey);
361
+ } catch (e) {
362
+ throw e instanceof UpdateError ? e : new UpdateError('updateUnreachable', (e as Error).message);
363
+ }
364
+ const { manifest } = release;
365
+ if (!isNewerVersion(manifest.version, opts.version)) {
366
+ throw new UpdateError('updateUpToDate', `Already up to date (v${opts.version}).`, { version: opts.version });
367
+ }
368
+ const file = manifest.files[label];
369
+ if (!file) throw new UpdateError('updateNoBuild', `Release ${manifest.version} has no build for ${label}.`, { version: manifest.version, label });
370
+ const { fresh, old } = updatePaths(execPath);
371
+ let moved = false;
372
+ let installed = false;
373
+ try {
374
+ await release.download(file, fresh);
375
+ if ((await fs.sha256(fresh)) !== file.sha256) {
376
+ throw new UpdateError('updateChecksum', 'The copied file does not match the release checksum.');
377
+ }
378
+ await fs.remove(old);
379
+ // Windows allows renaming a running exe, not replacing it.
380
+ await fs.rename(execPath, old);
381
+ moved = true;
382
+ await fs.rename(fresh, execPath);
383
+ installed = true;
384
+ const args = opts.relaunchArgs ?? process.argv.slice(2).filter((a) => a !== '--after-update');
385
+ await (opts.start ?? startDetached)([execPath, '--after-update', ...args]);
386
+ } catch (e) {
387
+ await rollback({ fs, exe: execPath, fresh, old, moved, installed, log });
388
+ throw e instanceof UpdateError ? e : new UpdateError('updateFailed', `Update failed: ${(e as Error).message}`, { message: (e as Error).message });
389
+ }
390
+ log(`updating ${opts.version} → ${manifest.version}`);
391
+ return { version: manifest.version };
392
+ } finally {
393
+ applying = false;
394
+ }
395
+ };
396
+
397
+ return {
398
+ start: () => void check(),
399
+ check,
400
+ apply,
401
+ state: () => state,
402
+ stop: () => void schedule(null),
403
+ };
404
+ }
405
+
406
+ /** Puts the original exe back under its name; every step is attempted and logged. */
407
+ async function rollback(s: { fs: UpdateFs; exe: string; fresh: string; old: string; moved: boolean; installed: boolean; log: (msg: string) => void }) {
408
+ const attempt = async (what: string, fn: () => Promise<void>) => {
409
+ try {
410
+ await fn();
411
+ } catch (e) {
412
+ s.log(`rollback: ${what} failed: ${(e as Error).message}`);
413
+ }
414
+ };
415
+ if (s.installed) await attempt('move new exe aside', () => s.fs.rename(s.exe, s.fresh));
416
+ if (s.moved) await attempt('restore exe', () => s.fs.rename(s.old, s.exe));
417
+ await attempt('remove new exe', () => s.fs.remove(s.fresh));
418
+ }
419
+
420
+ /**
421
+ * GET /api/update (state), POST /api/update/check (fresh state) and POST /api/update/apply, which
422
+ * swaps in the released exe, answers `{ version }`, then calls `quit` (the shell's `ctx.quit`; the
423
+ * new exe is already starting).
424
+ */
425
+ export function updateRoutes(updater: Updater, quit: () => void): Routes {
426
+ return {
427
+ 'GET /api/update': () => updater.state(),
428
+ 'POST /api/update/check': () => updater.check(),
429
+ 'POST /api/update/apply': async () => {
430
+ const result = await updater.apply();
431
+ setTimeout(quit, QUIT_DELAY_MS);
432
+ return result;
433
+ },
434
+ };
435
+ }
package/src/wasm.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare module '*.wasm' {
2
+ const path: string;
3
+ export default path;
4
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.