@deckbuilderkit/build 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/src/release.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { copyFileSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { bumpVersion, MANIFEST, MANIFEST_SIG, type ReleaseManifest, type VersionBump } from '@deckbuilderkit/server-core';
4
+ import { type AppDir, workspaceRoot } from './app.ts';
5
+ import { buildExe, buildWeb, releasePrefix, type Target } from './build.ts';
6
+ import { loadSigningKey, signManifest } from './keys.ts';
7
+
8
+ export const BUMPS: VersionBump[] = ['patch', 'minor', 'major'];
9
+
10
+ export interface ReleaseOptions {
11
+ /** Release folder root; files go to `<to>/<app name>/`, where the updater's folderSource looks. */
12
+ to: string;
13
+ bump: VersionBump;
14
+ targets: Target[];
15
+ /** Publishes the same files, then puts package.json back and leaves git alone. */
16
+ dryRun: boolean;
17
+ /** Runs typecheck and tests first. */
18
+ checks: boolean;
19
+ /** Publishes without latest.json.sig; builds with an update key then refuse the release. */
20
+ unsigned: boolean;
21
+ }
22
+
23
+ /** The app's private signing key; throws without one, unless `unsigned` (then null and a warning). */
24
+ function signingKey(app: AppDir, unsigned: boolean): string | null {
25
+ if (unsigned) {
26
+ console.warn(`warning: --unsigned: no ${MANIFEST_SIG}; builds with an update key refuse this release`);
27
+ return null;
28
+ }
29
+ const key = loadSigningKey(app.id);
30
+ if (!key) throw new Error('No signing key: run `deckbuilder keygen` (or pass --unsigned).');
31
+ return key;
32
+ }
33
+
34
+ interface ManifestFiles {
35
+ /** The exact bytes of latest.json. */
36
+ bytes: Uint8Array;
37
+ /** Their signature, or null when unsigned. */
38
+ sig: string | null;
39
+ }
40
+
41
+ function manifestFiles(manifest: ReleaseManifest, key: string | null): ManifestFiles {
42
+ const bytes = new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}\n`);
43
+ return { bytes, sig: key ? `${signManifest(bytes, key)}\n` : null };
44
+ }
45
+
46
+ /** Writes latest.json.sig, or removes a stale one when unsigned. */
47
+ function writeSig(dir: string, sig: string | null): void {
48
+ if (sig) writeFileSync(join(dir, MANIFEST_SIG), sig);
49
+ else rmSync(join(dir, MANIFEST_SIG), { force: true });
50
+ }
51
+
52
+ /** Tags of an app's releases start with its folder name: `gallery-v0.3.0`. */
53
+ const tagPrefix = (app: AppDir): string => `${basename(app.dir)}-v`;
54
+
55
+ /** Runs git in the workspace root; returns trimmed stdout, throws with stderr on failure. */
56
+ const rootGit =
57
+ (root: string) =>
58
+ (args: string[]): string => {
59
+ const r = Bun.spawnSync(['git', ...args], { cwd: root });
60
+ if (r.exitCode !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr.toString().trim()}`);
61
+ return r.stdout.toString().trim();
62
+ };
63
+
64
+ const sha256 = async (path: string): Promise<string> => new Bun.CryptoHasher('sha256').update(await Bun.file(path).arrayBuffer()).digest('hex');
65
+
66
+ /**
67
+ * Bumps the version, builds the exes and publishes them with latest.json, then commits the bump
68
+ * and tags `<app folder>-v<version>` locally. Never pushes.
69
+ */
70
+ export async function release(app: AppDir, opts: ReleaseOptions): Promise<void> {
71
+ const root = await workspaceRoot(app.dir);
72
+ const git = rootGit(root);
73
+ const sh = (cmd: string[]) => {
74
+ console.log(`> ${cmd.join(' ')}`);
75
+ if (Bun.spawnSync(cmd, { cwd: root, stdio: ['ignore', 'inherit', 'inherit'] }).exitCode !== 0) throw new Error(`failed: ${cmd.join(' ')}`);
76
+ };
77
+
78
+ if (!opts.dryRun && git(['status', '--porcelain']) !== '') {
79
+ throw new Error('Commit or stash your changes first: the release is built from HEAD and tagged.');
80
+ }
81
+ const version = bumpVersion(app.version, opts.bump);
82
+ const prefix = tagPrefix(app);
83
+ const tag = `${prefix}${version}`;
84
+ if (git(['tag', '--list', tag]) !== '') throw new Error(`Tag ${tag} already exists.`);
85
+ const key = signingKey(app, opts.unsigned);
86
+ const notes = releaseNotes(git, prefix, version);
87
+
88
+ const pkgPath = join(app.dir, 'package.json');
89
+ const pkgText = readFileSync(pkgPath, 'utf8');
90
+ // The exe reads its version from package.json, so even a dry run builds with the bump.
91
+ writeFileSync(pkgPath, pkgText.replace(`"version": "${app.version}"`, `"version": "${version}"`));
92
+ try {
93
+ if (opts.checks) {
94
+ sh(['bun', 'run', 'typecheck']);
95
+ sh(['bun', 'test']);
96
+ }
97
+ const next = { ...app, version };
98
+ buildWeb(next);
99
+ const exes: string[] = [];
100
+ for (const target of opts.targets) exes.push(await buildExe(next, target));
101
+ const manifest = await buildManifest(next, exes, notes);
102
+ const dest = join(opts.to, app.name);
103
+ await publish(exes, dest, manifest, manifestFiles(manifest, key));
104
+ console.log(`published ${tag} to ${dest}`);
105
+ } catch (e) {
106
+ writeFileSync(pkgPath, pkgText);
107
+ throw e;
108
+ }
109
+
110
+ if (opts.dryRun) {
111
+ writeFileSync(pkgPath, pkgText);
112
+ console.log(`dry run: package.json stays ${app.version}; no commit, no tag ${tag}`);
113
+ } else {
114
+ git(['commit', '-m', `Release ${tag}`, '--', pkgPath]);
115
+ git(['tag', tag]);
116
+ console.log(`committed and tagged ${tag}; push it yourself when ready`);
117
+ }
118
+ }
119
+
120
+ /** The manifest for built exes named `<name>-<version>-<label>[.exe]`: label and sha256 per file. */
121
+ export async function buildManifest(app: AppDir, exes: string[], notes: string): Promise<ReleaseManifest> {
122
+ const manifest: ReleaseManifest = { version: app.version, notes, date: new Date().toISOString(), files: {} };
123
+ const prefix = releasePrefix(app);
124
+ for (const exe of exes) {
125
+ const file = basename(exe);
126
+ if (!file.startsWith(prefix)) throw new Error(`${file} does not start with ${prefix}`);
127
+ manifest.files[file.slice(prefix.length).replace(/\.exe$/, '')] = { file, sha256: await sha256(exe) };
128
+ }
129
+ return manifest;
130
+ }
131
+
132
+ /**
133
+ * `deckbuilder manifest`: writes release/latest.json for the exes of the current version, with
134
+ * notes since the previous release tag (what the release workflow uploads next to the exes).
135
+ */
136
+ export async function writeManifest(app: AppDir, unsigned: boolean): Promise<void> {
137
+ const key = signingKey(app, unsigned);
138
+ const dir = join(app.dir, 'release');
139
+ const prefix = releasePrefix(app);
140
+ const exes = readdirSync(dir)
141
+ .filter((f) => f.startsWith(prefix))
142
+ .map((f) => join(dir, f));
143
+ if (exes.length === 0) throw new Error(`No ${prefix}* files in ${dir}; run build first.`);
144
+ const git = rootGit(await workspaceRoot(app.dir));
145
+ const manifest = await buildManifest(app, exes, releaseNotes(git, tagPrefix(app), app.version));
146
+ const { bytes, sig } = manifestFiles(manifest, key);
147
+ writeSig(dir, sig);
148
+ writeFileSync(join(dir, MANIFEST), bytes);
149
+ console.log(`wrote ${join(dir, MANIFEST)}${sig ? ` and ${MANIFEST_SIG}` : ''} (${Object.keys(manifest.files).join(', ')})`);
150
+ }
151
+
152
+ /** Commit summaries since the app's last release tag, one `- ` line each. */
153
+ export function releaseNotes(git: (args: string[]) => string, prefix: string, version: string): string {
154
+ let since: string;
155
+ try {
156
+ since = git(['describe', '--tags', '--abbrev=0', '--match', `${prefix}*`, '--exclude', `${prefix}${version}`]);
157
+ } catch {
158
+ return '- First release';
159
+ }
160
+ const lines = git(['log', '--format=%s', `${since}..HEAD`])
161
+ .split('\n')
162
+ .filter((s) => s && !s.startsWith('Release '));
163
+ return lines.length > 0 ? lines.map((s) => `- ${s}`).join('\n') : '- Maintenance release';
164
+ }
165
+
166
+ /**
167
+ * Copies the exes, then writes latest.json last and atomically, after every copy is verified. Its
168
+ * signature goes in first, so a reader never sees a new manifest without it.
169
+ */
170
+ async function publish(exes: string[], dest: string, manifest: ReleaseManifest, { bytes, sig }: ManifestFiles): Promise<void> {
171
+ mkdirSync(dest, { recursive: true });
172
+ for (const exe of exes) {
173
+ const entry = Object.values(manifest.files).find((f) => f.file === basename(exe))!;
174
+ const target = join(dest, entry.file);
175
+ copyFileSync(exe, target);
176
+ if ((await sha256(target)) !== entry.sha256) throw new Error(`${target} does not match the built exe after copying`);
177
+ }
178
+ writeSig(dest, sig);
179
+ const tmp = join(dest, `${MANIFEST}.tmp`);
180
+ writeFileSync(tmp, bytes);
181
+ renameSync(tmp, join(dest, MANIFEST));
182
+ }
@@ -0,0 +1,10 @@
1
+ import type { AppIdentity } from '@deckbuilderkit/server-core/protocol';
2
+
3
+ /**
4
+ * Identity of the app, shared by the server, the build and the web UI.
5
+ * When starting a new app from this one, change these first (see README "New app").
6
+ */
7
+ export const APP: AppIdentity = {
8
+ name: '__APP_NAME__',
9
+ id: '__APP_ID__',
10
+ };
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@deckbuilderkit/__APP_DIR__",
3
+ "version": "0.1.0",
4
+ "description": "__APP_NAME__",
5
+ "private": true,
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "deckbuilder dev",
9
+ "dev:window": "deckbuilder dev --window",
10
+ "start": "bun server/main.ts",
11
+ "start:browser": "bun server/main.ts --browser",
12
+ "build:web": "deckbuilder build:web",
13
+ "build": "deckbuilder build"
14
+ },
15
+ "dependencies": {
16
+ "@deckbuilderkit/platform": "workspace:*",
17
+ "@deckbuilderkit/server-core": "workspace:*",
18
+ "@deckbuilderkit/server-mods": "workspace:*",
19
+ "@deckbuilderkit/shell": "workspace:*",
20
+ "@deckbuilderkit/ui": "workspace:*",
21
+ "react": "^18.3.1",
22
+ "react-dom": "^18.3.1"
23
+ },
24
+ "devDependencies": {
25
+ "@deckbuilderkit/build": "workspace:*",
26
+ "@types/react": "^18.3.12",
27
+ "@types/react-dom": "^18.3.1",
28
+ "@vitejs/plugin-react": "^4.3.4",
29
+ "vite": "^6.0.3"
30
+ }
31
+ }
@@ -0,0 +1,95 @@
1
+ import { join } from 'node:path';
2
+ import { appHome, createAutostart, pickFolder } from '@deckbuilderkit/platform';
3
+ import { updatePublicKey } from '@deckbuilderkit/server-core';
4
+ import {
5
+ createUpdater,
6
+ folderSource,
7
+ fromPlatform,
8
+ githubSource,
9
+ infoRoutes,
10
+ pickedPaths,
11
+ settingsRoutes,
12
+ settingsStore,
13
+ themeHead,
14
+ updateRoutes,
15
+ type BaseSettings,
16
+ } from '@deckbuilderkit/server-mods';
17
+ import { runApp } from '@deckbuilderkit/shell';
18
+ import { type Keymap, validKeymap } from '@deckbuilderkit/ui/keybindings';
19
+ import { APP } from '../app.ts';
20
+ import pkg from '../package.json';
21
+ import { routes } from './routes.ts';
22
+
23
+ export interface Settings extends BaseSettings {
24
+ /** Keybinding overrides by command id, checked by `validKeymap`. */
25
+ keybindings?: Keymap;
26
+ /** Start at Windows login (exe only). */
27
+ autostart: boolean;
28
+ /** Tray icon, so closing the window keeps the app running; read at startup. */
29
+ closeToTray: boolean;
30
+ /**
31
+ * Folder `deckbuilder release --to` publishes to; empty: GitHub releases when `APP.repo` is set,
32
+ * else no update checks. Set only through the folder dialog of `POST /api/settings/update-folder`.
33
+ */
34
+ updateFolder: string;
35
+ }
36
+
37
+ const dataDir = appHome(APP.id);
38
+ const autostart = createAutostart({ name: APP.name });
39
+ /** The folder the update-folder dialog returned; `apply` takes it once. */
40
+ const pickedUpdateFolder = pickedPaths({ max: 1 });
41
+ const settings = settingsStore<Settings>({
42
+ dir: dataDir,
43
+ defaults: { theme: 'dark', autostart: false, closeToTray: false, updateFolder: '' },
44
+ apply(patch, next) {
45
+ if (typeof patch.autostart === 'boolean') next.autostart = patch.autostart;
46
+ if (typeof patch.closeToTray === 'boolean') next.closeToTray = patch.closeToTray;
47
+ // A client may only clear it: a typed path could be any UNC share.
48
+ if (patch.updateFolder === '' || (typeof patch.updateFolder === 'string' && pickedUpdateFolder.take(patch.updateFolder))) {
49
+ next.updateFolder = patch.updateFolder;
50
+ }
51
+ const keybindings = validKeymap(patch.keybindings);
52
+ if (keybindings) next.keybindings = keybindings;
53
+ },
54
+ onChange(next, prev) {
55
+ if (next.autostart !== prev.autostart) void autostart.set(next.autostart);
56
+ if (next.updateFolder !== prev.updateFolder) void updater.check();
57
+ },
58
+ });
59
+ const updater = createUpdater({
60
+ version: pkg.version,
61
+ publicKey: updatePublicKey(join(import.meta.dir, '..', 'update-key.pub')),
62
+ source: async () => {
63
+ const { updateFolder } = await settings.load();
64
+ if (updateFolder) return folderSource(updateFolder, { subfolder: APP.name });
65
+ return APP.repo ? githubSource({ repo: APP.repo, tagPrefix: '__APP_DIR__-v' }) : null;
66
+ },
67
+ });
68
+
69
+ const { closeToTray } = await settings.load();
70
+
71
+ await runApp({
72
+ app: APP,
73
+ routes: (ctx) => ({
74
+ ...infoRoutes({ app: APP, version: pkg.version, dataDir }),
75
+ ...settingsRoutes(settings),
76
+ 'POST /api/settings/update-folder': async ({ json }) => {
77
+ const { title } = await json<{ title?: unknown }>();
78
+ const path = await pickFolder({ title: typeof title === 'string' ? title : undefined }).catch(fromPlatform);
79
+ if (path === null) return settings.load();
80
+ pickedUpdateFolder.add(path);
81
+ return settings.save({ updateFolder: path });
82
+ },
83
+ ...updateRoutes(updater, ctx.quit),
84
+ ...routes(ctx),
85
+ }),
86
+ webDir: join(import.meta.dir, '../dist/web'),
87
+ transformIndex: themeHead(settings),
88
+ urlProtocol: true,
89
+ tray: closeToTray,
90
+ async onStart() {
91
+ updater.start();
92
+ // Repairs the Run entry after the exe moved.
93
+ if ((await settings.load()).autostart) await autostart.fixUp();
94
+ },
95
+ });
@@ -0,0 +1,10 @@
1
+ import type { Routes } from '@deckbuilderkit/server-core';
2
+ import type { StartContext } from '@deckbuilderkit/shell';
3
+
4
+ /**
5
+ * This app's own endpoints. Keys are "METHOD /path"; handlers return a JSON-serializable value
6
+ * or throw an AppError (`invalid`, `notFound`) that the UI shows translated as `error.<code>`.
7
+ */
8
+ export const routes = (_ctx: StartContext): Routes => ({
9
+ // 'GET /api/things': async () => [],
10
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": { "lib": ["ESNext"], "types": ["bun"] },
4
+ "include": ["app.ts", "server", "vite.config.ts"]
5
+ }
@@ -0,0 +1,20 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import { DEV_PORT } from '@deckbuilderkit/server-core/protocol';
4
+
5
+ // Dev: `deckbuilder dev` runs the server on DEV_PORT; Vite proxies the API there. changeOrigin
6
+ // stays false: the server's Origin check needs the browser's Origin and Host.
7
+ export default defineConfig({
8
+ root: 'web',
9
+ plugins: [react()],
10
+ build: {
11
+ outDir: '../dist/web',
12
+ emptyOutDir: true,
13
+ },
14
+ server: {
15
+ proxy: {
16
+ // ws: the terminal socket lives under /api
17
+ '/api': { target: `http://127.0.0.1:${DEV_PORT}`, changeOrigin: false, ws: true },
18
+ },
19
+ },
20
+ });
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark" data-layout="original">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>__APP_NAME__</title>
7
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
2
+ <rect width="32" height="32" rx="7" fill="#120d2b"/>
3
+ <g transform="translate(4 4) scale(.75)">
4
+ <rect x="12" y="3" width="17" height="19" rx="3.5" fill="#9b8cff" fill-opacity=".4"/>
5
+ <rect x="7.5" y="7" width="17" height="19" rx="3.5" fill="#120d2b" stroke="#120d2b" stroke-width="4"/>
6
+ <rect x="7.5" y="7" width="17" height="19" rx="3.5" fill="#9b8cff" fill-opacity=".7"/>
7
+ <rect x="3" y="11" width="17" height="19" rx="3.5" fill="#120d2b" stroke="#120d2b" stroke-width="4"/>
8
+ <rect x="3" y="11" width="17" height="19" rx="3.5" fill="#9b8cff"/>
9
+ </g>
10
+ </svg>
@@ -0,0 +1,187 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { PALETTES } from '@deckbuilderkit/server-core/palettes';
3
+ import { AppFrame, type CommandDef, CommandPalette, detectLanguage, effectiveKeys, ErrorBanner, GLOBAL, Icon, type IconName, isDesktop, KeybindingProvider, LayoutProvider, paletteCommand, type PaletteItem, setLanguage, Sidebar, Spinner, useAsync, useCommand, useCommandPalette, useHashRoute, useHeartbeat, useKeybindings, useTheme, useZoom } from '@deckbuilderkit/ui';
4
+ import { APP } from '../../app';
5
+ import { api, type Settings } from './api';
6
+ import { type MessageKey, t } from './i18n';
7
+ import { Home } from './screens/Home';
8
+ import { SettingsModal } from './screens/SettingsModal';
9
+
10
+ const SCREENS = ['home'] as const;
11
+ type ScreenId = (typeof SCREENS)[number];
12
+
13
+ const NAV: Array<{ title: MessageKey; items: Array<{ id: ScreenId; label: MessageKey; icon: IconName }> }> = [
14
+ {
15
+ title: 'nav.section.main',
16
+ items: [
17
+ { id: 'home', label: 'nav.home', icon: 'home' },
18
+ ],
19
+ },
20
+ ];
21
+
22
+ const navCommandId = (screen: ScreenId) => `nav.${screen}`;
23
+ const SETTINGS_COMMAND = 'app.settings';
24
+ const KEYS_COMMAND = 'app.keybindings';
25
+
26
+ /** Every shortcut of the app; built at render so the labels follow the language. */
27
+ function commands(): CommandDef[] {
28
+ const nav = NAV.flatMap((s) => s.items).map((item, i): CommandDef => ({
29
+ id: navCommandId(item.id), scope: GLOBAL, label: t(item.label), keys: [`Ctrl+${i + 1}`], inInput: true,
30
+ }));
31
+ // Settings and keybindings are modals over the screen, not screens; both are VS Code's keys.
32
+ const overlays: CommandDef[] = [
33
+ { id: SETTINGS_COMMAND, scope: GLOBAL, label: t('nav.settings'), keys: ['Ctrl+,'], inInput: true },
34
+ { id: KEYS_COMMAND, scope: GLOBAL, label: t('nav.keys'), keys: ['Ctrl+K Ctrl+S'], inInput: true },
35
+ ];
36
+ return [paletteCommand(), ...nav, ...overlays];
37
+ }
38
+
39
+ // Retrying can't revive a used launch code or a stale token; the user has to open the app again.
40
+ const SESSION_ERRORS = ['invalidCode', 'invalidToken'];
41
+
42
+ // Settings is a modal over the current screen, not a screen; 'keys' opens it at Keyboard › Shortcuts. App links
43
+ // (toasts, second instances) still open them with `#/settings` and `#/keys`; `#/settings/<section key>` opens a section.
44
+ type Overlay = 'settings' | 'keys' | `settings/${string}`;
45
+ const overlayOfHash = (): Overlay | null => {
46
+ const m = /^#\/?(settings|keys)(?:\/([\w.-]+))?\/?$/.exec(window.location.hash);
47
+ if (!m) return null;
48
+ return m[1] === 'settings' && m[2] ? `settings/${m[2]}` : (m[1] as Overlay);
49
+ };
50
+
51
+ /** Loads the settings first, so the first paint already has the right theme and language. */
52
+ export function App() {
53
+ const boot = useAsync(() => api.settings(), []);
54
+ if (boot.data?.language) setLanguage(boot.data.language);
55
+
56
+ if (boot.error) {
57
+ return <div className="boot"><ErrorBanner error={boot.error} onRetry={SESSION_ERRORS.includes(boot.error.code ?? '') ? undefined : boot.reload} /></div>;
58
+ }
59
+ if (!boot.data) {
60
+ return <div className="boot"><Spinner /> <span className="muted">{t('common.loading')}</span></div>;
61
+ }
62
+ return <Shell initialSettings={boot.data} />;
63
+ }
64
+
65
+ function Shell({ initialSettings }: { initialSettings: Settings }) {
66
+ const [settings, setSettings] = useState(initialSettings);
67
+ const [overlay, setOverlay] = useState(overlayOfHash);
68
+ const shown = useRef<ScreenId>('home');
69
+ // Before useHashRoute, so this listener runs first: it puts the shown screen back in the hash for the router.
70
+ useEffect(() => {
71
+ const take = () => {
72
+ const open = overlayOfHash();
73
+ if (!open) return;
74
+ window.history.replaceState(null, '', `#/${shown.current}`);
75
+ setOverlay(open);
76
+ };
77
+ take();
78
+ window.addEventListener('hashchange', take);
79
+ return () => window.removeEventListener('hashchange', take);
80
+ }, []);
81
+ const [screen, go] = useHashRoute(SCREENS, 'home');
82
+ shown.current = screen;
83
+ setLanguage(settings.language ?? detectLanguage());
84
+ return (
85
+ <KeybindingProvider defs={commands()} keymap={settings.keybindings ?? {}} scope={screen}>
86
+ <Main settings={settings} setSettings={setSettings} screen={screen} go={go} overlay={overlay} setOverlay={setOverlay} />
87
+ </KeybindingProvider>
88
+ );
89
+ }
90
+
91
+ interface MainProps {
92
+ settings: Settings;
93
+ setSettings: React.Dispatch<React.SetStateAction<Settings>>;
94
+ screen: ScreenId;
95
+ go: (screen: ScreenId) => void;
96
+ overlay: Overlay | null;
97
+ setOverlay: (overlay: Overlay | null) => void;
98
+ }
99
+
100
+ function Main({ settings, setSettings, screen, go, overlay, setOverlay }: MainProps) {
101
+ const [stopped, setStopped] = useState(false);
102
+ useHeartbeat();
103
+ const palette = useCommandPalette();
104
+
105
+ const { kind } = useTheme(settings);
106
+ useZoom(settings.zoom);
107
+
108
+ useEffect(() => {
109
+ const item = NAV.flatMap((s) => s.items).find((i) => i.id === screen);
110
+ document.title = item ? `${t(item.label)} · ${APP.name}` : APP.name;
111
+ }, [screen, settings.language]);
112
+
113
+ const save = useCallback((patch: Partial<Settings>) => {
114
+ setSettings((s) => ({ ...s, ...patch }));
115
+ api.saveSettings(patch).catch(() => { /* preferences are best-effort */ });
116
+ }, []);
117
+
118
+ const navItems = NAV.flatMap((s) => s.items);
119
+ const defs = commands();
120
+ // The nav list is a constant, so the hooks run in the same order every render.
121
+ for (const item of navItems) useCommand(defs.find((d) => d.id === navCommandId(item.id))!, () => go(item.id));
122
+ useCommand(defs.find((d) => d.id === SETTINGS_COMMAND)!, () => setOverlay('settings'));
123
+ useCommand(defs.find((d) => d.id === KEYS_COMMAND)!, () => setOverlay('keys'));
124
+ const keys = useKeybindings();
125
+ const chordOf = (id: string) => (keys ? effectiveKeys(keys.defs, keys.keymap).get(id)?.[0] : undefined);
126
+
127
+ const quit = useCallback(() => { api.quit().then(() => setStopped(true)).catch(() => { /* already gone */ }); }, []);
128
+ const nextTheme = kind === 'dark' ? 'light' : 'dark';
129
+ const items = useMemo<PaletteItem[]>(() => {
130
+ const goTo = navItems.map((i): PaletteItem => ({
131
+ id: `go:${i.id}`, group: t('palette.group.go'), label: t(i.label), hint: chordOf(navCommandId(i.id)), keywords: [i.id], run: () => go(i.id),
132
+ }));
133
+ const overlays: PaletteItem[] = [
134
+ { id: 'app:settings', group: t('palette.group.go'), label: t('nav.settings'), hint: chordOf(SETTINGS_COMMAND), keywords: ['settings', 'preferences'], run: () => setOverlay('settings') },
135
+ { id: 'app:keys', group: t('palette.group.go'), label: t('nav.keys'), hint: chordOf(KEYS_COMMAND), keywords: ['keybindings', 'shortcuts', 'keys'], run: () => setOverlay('keys') },
136
+ ];
137
+ const toggle: PaletteItem = {
138
+ id: 'theme:toggle', group: t('palette.group.theme'), label: t(`nav.theme.${nextTheme}`), keywords: ['theme', 'dark', 'light'],
139
+ run: () => save({ theme: nextTheme }),
140
+ };
141
+ const palettes = PALETTES.map((p): PaletteItem => ({
142
+ id: `palette:${p.id}`, group: t('palette.group.theme'), label: t('palette.theme', { name: p.label }), keywords: ['theme', p.family, p.kind],
143
+ run: () => save({ theme: p.kind, ...(p.kind === 'dark' ? { paletteDark: p.id } : { paletteLight: p.id }) }),
144
+ }));
145
+ const quitItem: PaletteItem[] = isDesktop ? [] : [{
146
+ id: 'app:quit', group: t('palette.group.app'), label: t('palette.quit', { name: APP.name }), keywords: ['quit', 'exit', 'stop'], run: quit,
147
+ }];
148
+ return [...goTo, ...overlays, toggle, ...palettes, ...quitItem];
149
+ }, [go, save, quit, nextTheme, settings.language, keys, setOverlay]);
150
+
151
+ if (stopped) return <div className="boot"><span>{t('app.stopped', { name: APP.name })}</span></div>;
152
+
153
+ const sections = NAV.map((s) => ({ title: t(s.title), items: s.items.map((i) => ({ ...i, label: t(i.label), hint: chordOf(navCommandId(i.id)) })) }));
154
+
155
+ const footer = (
156
+ <>
157
+ <button className="btn btn-ghost btn-sm btn-icon" onClick={() => save({ theme: nextTheme })} title={t(`nav.theme.${nextTheme}`)} aria-label={t(`nav.theme.${nextTheme}`)}>
158
+ <Icon name={kind === 'dark' ? 'sun' : 'moon'} />
159
+ </button>
160
+ <button className={`btn btn-ghost btn-sm${overlay ? ' active' : ''} btn-icon`} onClick={() => setOverlay('settings')} title={t('nav.settings')} aria-label={t('nav.settings')}>
161
+ <Icon name="settings" />
162
+ </button>
163
+ <button className="btn btn-ghost btn-sm btn-icon" onClick={() => palette.setOpen(true)} title={t('nav.palette')} aria-label={t('nav.palette')}>
164
+ <Icon name="search" />
165
+ </button>
166
+ </>
167
+ );
168
+
169
+ return (
170
+ <LayoutProvider layout={settings.layout} icons={settings.icons} mainMenu={settings.mainMenu} dock={settings.dock} layoutIcon={settings.layoutIcon}>
171
+ <AppFrame tools={footer} sidebar={<Sidebar name={APP.name} sections={sections} current={screen} onSelect={go} footer={footer} />}>
172
+ <main className="main">{renderScreen(screen)}</main>
173
+ {overlay && (
174
+ <SettingsModal key={overlay} settings={settings} onChange={save} onQuit={() => setStopped(true)} onClose={() => setOverlay(null)} initialSection={overlay === 'keys' ? 'keyboard.shortcuts' : overlay.startsWith('settings/') ? overlay.slice('settings/'.length) : undefined} />
175
+ )}
176
+ {palette.open && <CommandPalette items={items} onClose={() => palette.setOpen(false)} />}
177
+ </AppFrame>
178
+ </LayoutProvider>
179
+ );
180
+ }
181
+
182
+ function renderScreen(screen: ScreenId) {
183
+ switch (screen) {
184
+ default:
185
+ return <Home />;
186
+ }
187
+ }
@@ -0,0 +1,38 @@
1
+ import type { ThemeSettings } from '@deckbuilderkit/server-core/palettes';
2
+ import type { UpdateState } from '@deckbuilderkit/server-core/protocol';
3
+ import { type Keymap, type Language, request } from '@deckbuilderkit/ui';
4
+
5
+ export interface Settings extends ThemeSettings {
6
+ language?: Language;
7
+ /** The main menu shows only its icons. */
8
+ /** Keybinding overrides by command id; the server validates it. */
9
+ keybindings?: Keymap;
10
+ autostart: boolean;
11
+ /** Tray icon; read at startup. */
12
+ closeToTray: boolean;
13
+ updateFolder: string;
14
+ }
15
+
16
+ /** GET /api/info (infoRoutes in @deckbuilderkit/server-mods). */
17
+ export interface Info {
18
+ name: string;
19
+ version: string;
20
+ platform: string;
21
+ arch: string;
22
+ hostname: string;
23
+ bun: string;
24
+ dataDir: string;
25
+ startedAt: string;
26
+ }
27
+
28
+ export const api = {
29
+ info: () => request<Info>('GET', '/api/info'),
30
+ settings: () => request<Settings>('GET', '/api/settings'),
31
+ saveSettings: (patch: Partial<Settings>) => request<Settings>('PUT', '/api/settings', {}, patch),
32
+ /** Shows the server's folder dialog and saves the result as `updateFolder` (unchanged when cancelled). */
33
+ pickUpdateFolder: (title: string) => request<Settings>('POST', '/api/settings/update-folder', {}, { title }),
34
+ update: () => request<UpdateState>('GET', '/api/update'),
35
+ checkUpdate: () => request<UpdateState>('POST', '/api/update/check'),
36
+ applyUpdate: () => request<{ version: string }>('POST', '/api/update/apply'),
37
+ quit: () => request<{ ok: true }>('POST', '/api/quit'),
38
+ };