@dormer/cli 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -2
- package/bin/dormer.mjs +26 -0
- package/dist/cli.js +1301 -0
- package/dist/index.js +676 -0
- package/package.json +57 -4
- package/src/args.ts +68 -0
- package/src/backend.ts +142 -0
- package/src/cli.ts +45 -0
- package/src/commands/build.ts +105 -0
- package/src/commands/create.ts +132 -0
- package/src/commands/dev.ts +128 -0
- package/src/commands/doctor.ts +141 -0
- package/src/commands/inspect.ts +60 -0
- package/src/commands/package.ts +159 -0
- package/src/config.ts +151 -0
- package/src/host.ts +85 -0
- package/src/index.ts +8 -0
- package/src/resolve.ts +75 -0
- package/src/ui.ts +57 -0
- package/templates/react/README.md +34 -0
- package/templates/react/_gitignore +5 -0
- package/templates/react/dormer.config.json +16 -0
- package/templates/react/package.json +29 -0
- package/templates/react/renderer/index.html +12 -0
- package/templates/react/renderer/src/App.tsx +34 -0
- package/templates/react/renderer/src/main.tsx +6 -0
- package/templates/react/renderer/src/style.css +8 -0
- package/templates/react/renderer/src/vite-env.d.ts +2 -0
- package/templates/react/renderer/tsconfig.json +15 -0
- package/templates/react/renderer/vite.config.ts +7 -0
- package/templates/react/src/main.ts +34 -0
- package/templates/react/src/shared.ts +10 -0
- package/templates/react/tsconfig.json +15 -0
- package/templates/svelte/README.md +34 -0
- package/templates/svelte/_gitignore +5 -0
- package/templates/svelte/dormer.config.json +16 -0
- package/templates/svelte/package.json +27 -0
- package/templates/svelte/renderer/index.html +12 -0
- package/templates/svelte/renderer/src/App.svelte +32 -0
- package/templates/svelte/renderer/src/main.ts +5 -0
- package/templates/svelte/renderer/src/style.css +8 -0
- package/templates/svelte/renderer/src/vite-env.d.ts +2 -0
- package/templates/svelte/renderer/tsconfig.json +15 -0
- package/templates/svelte/renderer/vite.config.ts +7 -0
- package/templates/svelte/src/main.ts +34 -0
- package/templates/svelte/src/shared.ts +10 -0
- package/templates/svelte/tsconfig.json +15 -0
- package/templates/vanilla/README.md +34 -0
- package/templates/vanilla/_gitignore +5 -0
- package/templates/vanilla/dormer.config.json +16 -0
- package/templates/vanilla/package.json +24 -0
- package/templates/vanilla/renderer/index.html +12 -0
- package/templates/vanilla/renderer/src/main.ts +31 -0
- package/templates/vanilla/renderer/src/style.css +8 -0
- package/templates/vanilla/renderer/src/vite-env.d.ts +2 -0
- package/templates/vanilla/renderer/tsconfig.json +14 -0
- package/templates/vanilla/renderer/vite.config.ts +6 -0
- package/templates/vanilla/src/main.ts +34 -0
- package/templates/vanilla/src/shared.ts +10 -0
- package/templates/vanilla/tsconfig.json +15 -0
- package/templates/vue/README.md +34 -0
- package/templates/vue/_gitignore +5 -0
- package/templates/vue/dormer.config.json +16 -0
- package/templates/vue/package.json +27 -0
- package/templates/vue/renderer/index.html +12 -0
- package/templates/vue/renderer/src/App.vue +36 -0
- package/templates/vue/renderer/src/main.ts +5 -0
- package/templates/vue/renderer/src/style.css +8 -0
- package/templates/vue/renderer/src/vite-env.d.ts +8 -0
- package/templates/vue/renderer/tsconfig.json +15 -0
- package/templates/vue/renderer/vite.config.ts +7 -0
- package/templates/vue/src/main.ts +34 -0
- package/templates/vue/src/shared.ts +10 -0
- package/templates/vue/tsconfig.json +15 -0
package/src/host.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Locating and launching the native host binary (`dormer-host`).
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
6
|
+
import { CliError, EXIT, c } from "./ui.ts";
|
|
7
|
+
import { findPackageDir, findUp } from "./resolve.ts";
|
|
8
|
+
|
|
9
|
+
export const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const EXE = process.platform === "win32" ? ".exe" : "";
|
|
11
|
+
|
|
12
|
+
export interface HostLocation { path: string; source: "env" | "cargo-target" | "package"; tried: string[] }
|
|
13
|
+
|
|
14
|
+
/** The npm package that carries the prebuilt host binary for this platform (optional dependency of @dormer/cli). */
|
|
15
|
+
export function hostPackageName(): string { return `@dormer/host-${process.platform}-${process.arch}`; }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolution order (documented in README.md):
|
|
19
|
+
* 1. $DORMER_HOST_BIN
|
|
20
|
+
* 2. <cargo workspace>/target/{release,debug}/dormer-host, searching upward from the app root
|
|
21
|
+
* and from the CLI's own location (covers the monorepo and apps developed inside it)
|
|
22
|
+
* 3. node_modules/@dormer/host-<platform>-<arch>/bin/dormer-host (prebuilt package, an optional dependency of @dormer/cli)
|
|
23
|
+
*/
|
|
24
|
+
export function resolveHostBinary(appRoot: string): HostLocation {
|
|
25
|
+
const tried: string[] = [];
|
|
26
|
+
const env = process.env.DORMER_HOST_BIN;
|
|
27
|
+
if (env) {
|
|
28
|
+
if (isExecutable(env)) return { path: path.resolve(env), source: "env", tried };
|
|
29
|
+
throw new CliError(`DORMER_HOST_BIN is set but ${env} is not an executable file`, { exitCode: EXIT.missingTool, hints: ["unset DORMER_HOST_BIN or point it at a dormer-host binary"] });
|
|
30
|
+
}
|
|
31
|
+
const workspaces = new Set<string>();
|
|
32
|
+
for (const start of [appRoot, CLI_DIR]) { const w = findUp(start, "Cargo.toml"); if (w) workspaces.add(w); }
|
|
33
|
+
for (const w of workspaces) {
|
|
34
|
+
const targetDir = process.env.CARGO_TARGET_DIR ?? path.join(w, "target");
|
|
35
|
+
for (const profile of ["release", "debug"]) {
|
|
36
|
+
const candidate = path.join(targetDir, profile, `dormer-host${EXE}`);
|
|
37
|
+
tried.push(candidate);
|
|
38
|
+
if (isExecutable(candidate)) return { path: candidate, source: "cargo-target", tried };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const pkg = findPackageDir(appRoot, hostPackageName()) ?? findPackageDir(CLI_DIR, hostPackageName());
|
|
42
|
+
const fromPkg = pkg ? path.join(pkg, "bin", `dormer-host${EXE}`) : path.join(appRoot, "node_modules", hostPackageName(), "bin", `dormer-host${EXE}`);
|
|
43
|
+
tried.push(fromPkg);
|
|
44
|
+
if (isExecutable(fromPkg)) return { path: fromPkg, source: "package", tried };
|
|
45
|
+
throw new CliError("the native host binary (dormer-host) was not found", {
|
|
46
|
+
exitCode: EXIT.missingTool,
|
|
47
|
+
hints: [
|
|
48
|
+
`looked at:\n${tried.map((t) => ` ${t}`).join("\n")}`,
|
|
49
|
+
`inside the Dormer repository: ${c.bold("cargo build --release -p dormer-host")}`,
|
|
50
|
+
`or set ${c.bold("DORMER_HOST_BIN=/path/to/dormer-host")}`,
|
|
51
|
+
`(prebuilt binaries will ship as ${hostPackageName()} once published)`,
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isExecutable(file: string): boolean {
|
|
57
|
+
try { fs.accessSync(file, fs.constants.X_OK); return fs.statSync(file).isFile(); } catch { return false; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface HostLaunch {
|
|
61
|
+
host: string;
|
|
62
|
+
appDir: string;
|
|
63
|
+
entry: string;
|
|
64
|
+
node: string;
|
|
65
|
+
name: string;
|
|
66
|
+
dev?: boolean;
|
|
67
|
+
devUrl?: string;
|
|
68
|
+
/** Environment variables to forward through the host's scrubbing into the Node sidecar. */
|
|
69
|
+
passEnv?: string[];
|
|
70
|
+
env?: Record<string, string | undefined>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function hostArgs(l: HostLaunch): string[] {
|
|
74
|
+
const args = ["--app-dir", l.appDir, "--entry", l.entry, "--node", l.node, "--name", l.name];
|
|
75
|
+
if (l.dev) args.push("--dev");
|
|
76
|
+
if (l.devUrl) args.push("--dev-url", l.devUrl);
|
|
77
|
+
for (const v of new Set(l.passEnv ?? [])) args.push("--pass-env", v);
|
|
78
|
+
return args;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function spawnHost(l: HostLaunch): ChildProcess {
|
|
82
|
+
const env = { ...process.env, ...l.env };
|
|
83
|
+
env.RUST_LOG ??= "info";
|
|
84
|
+
return spawn(l.host, hostArgs(l), { stdio: "inherit", env });
|
|
85
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Programmatic surface of @dormer/cli (the `dormer` binary is bin/dormer.mjs → src/cli.ts).
|
|
2
|
+
export { loadApp, loadConfig, findAppRoot, resolvePaths, validateConfig, BUNDLE_TARGETS, type DormerConfig, type AppPaths, type BundleTarget } from "./config.ts";
|
|
3
|
+
export { resolveHostBinary, hostArgs, spawnHost, hostPackageName, type HostLocation, type HostLaunch } from "./host.ts";
|
|
4
|
+
export { bundleBackend, watchBackend, type BundleOptions, type BundleResult } from "./backend.ts";
|
|
5
|
+
export { buildApp, makeManifest, type AppManifest } from "./commands/build.ts";
|
|
6
|
+
export { makeBundleSpec, defaultTargets, resolveBundlerBinary, type BundleSpec } from "./commands/package.ts";
|
|
7
|
+
export { runChecks, capabilitySummary, type Check } from "./commands/doctor.ts";
|
|
8
|
+
export { CliError, EXIT } from "./ui.ts";
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Resolution helpers: find packages from the user's project (not from the CLI's own tree).
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { CliError, c } from "./ui.ts";
|
|
6
|
+
|
|
7
|
+
/** Finds `node_modules/<name>` walking up from `fromDir`. Returns the package directory. */
|
|
8
|
+
export function findPackageDir(fromDir: string, name: string): string | undefined {
|
|
9
|
+
let dir = path.resolve(fromDir);
|
|
10
|
+
for (;;) {
|
|
11
|
+
const candidate = path.join(dir, "node_modules", name);
|
|
12
|
+
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
|
|
13
|
+
const parent = path.dirname(dir);
|
|
14
|
+
if (parent === dir) return undefined;
|
|
15
|
+
dir = parent;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readJson<T = Record<string, unknown>>(file: string): T { return JSON.parse(fs.readFileSync(file, "utf8")) as T; }
|
|
20
|
+
|
|
21
|
+
/** Picks the ESM entry of a package from its exports map (or module/main fallbacks). */
|
|
22
|
+
export function packageEsmEntry(pkgDir: string): string {
|
|
23
|
+
const pkg = readJson<{ exports?: unknown; module?: string; main?: string }>(path.join(pkgDir, "package.json"));
|
|
24
|
+
const pick = (v: unknown): string | undefined => {
|
|
25
|
+
if (typeof v === "string") return v;
|
|
26
|
+
if (!v || typeof v !== "object") return undefined;
|
|
27
|
+
const o = v as Record<string, unknown>;
|
|
28
|
+
for (const k of ["import", "node", "default"]) if (o[k] !== undefined) { const r = pick(o[k]); if (r) return r; }
|
|
29
|
+
return undefined;
|
|
30
|
+
};
|
|
31
|
+
const fromExports = pkg.exports && typeof pkg.exports === "object" && !Array.isArray(pkg.exports) ? pick((pkg.exports as Record<string, unknown>)["."] ?? pkg.exports) : pick(pkg.exports);
|
|
32
|
+
const rel = fromExports ?? pkg.module ?? pkg.main ?? "index.js";
|
|
33
|
+
return path.join(pkgDir, rel);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Vite's programmatic API, loaded from the user's project so their plugins share the instance. */
|
|
37
|
+
export type ViteModule = typeof import("vite");
|
|
38
|
+
export async function loadVite(root: string): Promise<{ vite: ViteModule; version: string; dir: string }> {
|
|
39
|
+
const dir = findPackageDir(root, "vite");
|
|
40
|
+
if (!dir) throw new CliError(`vite is not installed in ${root}`, { hints: [`run ${c.bold("npm install")} in the app directory (vite is a devDependency of every template)`] });
|
|
41
|
+
const version = readJson<{ version: string }>(path.join(dir, "package.json")).version;
|
|
42
|
+
const vite = (await import(pathToFileURL(packageEsmEntry(dir)).href)) as ViteModule;
|
|
43
|
+
return { vite, version, dir };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function findViteConfig(rendererDir: string): string | undefined {
|
|
47
|
+
for (const n of ["vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs"]) {
|
|
48
|
+
const f = path.join(rendererDir, n);
|
|
49
|
+
if (fs.existsSync(f)) return f;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Finds a directory containing `marker` walking up from `start`. */
|
|
55
|
+
export function findUp(start: string, marker: string): string | undefined {
|
|
56
|
+
let dir = path.resolve(start);
|
|
57
|
+
for (;;) {
|
|
58
|
+
if (fs.existsSync(path.join(dir, marker))) return dir;
|
|
59
|
+
const parent = path.dirname(dir);
|
|
60
|
+
if (parent === dir) return undefined;
|
|
61
|
+
dir = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** First executable named `name` on PATH, or undefined. */
|
|
66
|
+
export function which(name: string): string | undefined {
|
|
67
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
68
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
69
|
+
for (const ext of exts) {
|
|
70
|
+
const f = path.join(dir, name + ext.toLowerCase());
|
|
71
|
+
try { fs.accessSync(f, fs.constants.X_OK); if (fs.statSync(f).isFile()) return f; } catch { /* keep looking */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Terminal output helpers, error type and exit codes shared by every command.
|
|
2
|
+
import { inspect } from "node:util";
|
|
3
|
+
|
|
4
|
+
/** Exit codes (sysexits-inspired, documented in README.md). */
|
|
5
|
+
export const EXIT = {
|
|
6
|
+
ok: 0,
|
|
7
|
+
failure: 1, // build failed, host exited non-zero, doctor found a failing check
|
|
8
|
+
missingTool: 2, // dormer-host / dormer-bundle not found
|
|
9
|
+
usage: 64, // bad arguments
|
|
10
|
+
config: 65, // dormer.config.json missing or invalid
|
|
11
|
+
noInput: 66, // a required input (template, entry, renderer dir) does not exist
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export class CliError extends Error {
|
|
15
|
+
exitCode: number;
|
|
16
|
+
hints: string[];
|
|
17
|
+
constructor(message: string, options: { exitCode?: number; hints?: string[] } = {}) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "CliError";
|
|
20
|
+
this.exitCode = options.exitCode ?? EXIT.failure;
|
|
21
|
+
this.hints = options.hints ?? [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && (process.stdout.isTTY || process.env.FORCE_COLOR);
|
|
26
|
+
const paint = (code: number) => (s: string) => (useColor ? `\u001b[${code}m${s}\u001b[0m` : s);
|
|
27
|
+
export const c = { dim: paint(2), bold: paint(1), red: paint(31), green: paint(32), yellow: paint(33), blue: paint(34), cyan: paint(36) };
|
|
28
|
+
|
|
29
|
+
const prefix = c.dim("[dormer]");
|
|
30
|
+
export const log = {
|
|
31
|
+
info: (msg: string): void => { console.log(`${prefix} ${msg}`); },
|
|
32
|
+
step: (msg: string): void => { console.log(`${prefix} ${c.cyan("›")} ${msg}`); },
|
|
33
|
+
ok: (msg: string): void => { console.log(`${prefix} ${c.green("✓")} ${msg}`); },
|
|
34
|
+
warn: (msg: string): void => { console.error(`${prefix} ${c.yellow("warning:")} ${msg}`); },
|
|
35
|
+
error: (msg: string): void => { console.error(`${prefix} ${c.red("error:")} ${msg}`); },
|
|
36
|
+
hint: (msg: string): void => { console.error(`${prefix} ${c.dim("hint:")} ${msg}`); },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Prints an error (with hints) and returns the exit code to use. */
|
|
40
|
+
export function reportError(err: unknown): number {
|
|
41
|
+
if (err instanceof CliError) {
|
|
42
|
+
log.error(err.message);
|
|
43
|
+
for (const h of err.hints) log.hint(h);
|
|
44
|
+
return err.exitCode;
|
|
45
|
+
}
|
|
46
|
+
const e = err as { message?: string; stack?: string };
|
|
47
|
+
log.error(e?.message ?? inspect(err));
|
|
48
|
+
if (process.env.DORMER_CLI_DEBUG && e?.stack) console.error(e.stack);
|
|
49
|
+
else log.hint("set DORMER_CLI_DEBUG=1 for a stack trace");
|
|
50
|
+
return EXIT.failure;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatBytes(n: number): string {
|
|
54
|
+
if (n < 1024) return `${n} B`;
|
|
55
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} kB`;
|
|
56
|
+
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A [Dormer](https://github.com/dormer) desktop app: a Node.js backend (`src/main.ts`) and a
|
|
4
|
+
React renderer (`renderer/`) shown in the operating system's WebView.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install
|
|
8
|
+
npm run dev # Vite dev server + native window; the backend is rebuilt on change
|
|
9
|
+
npm run build # dist/renderer, dist/main.js, dist/dormer-app.json
|
|
10
|
+
npm run package # installers (needs the dormer-bundle tool)
|
|
11
|
+
npx dormer doctor # checks Node, the host binary and platform prerequisites
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Layout
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
dormer.config.json name, identifier, version, per-window capabilities, Node version pin
|
|
18
|
+
src/main.ts backend: one window, ipcMain.handle("ping"), window-all-closed → quit
|
|
19
|
+
renderer/ Vite project: index.html, vite.config.ts (@vitejs/plugin-react + @dormer/vite-plugin), src/App.tsx
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## How the pieces talk
|
|
23
|
+
|
|
24
|
+
* `invoke("ping")` in the renderer resolves with whatever `ipcMain.handle("ping", ...)` returns.
|
|
25
|
+
* `on("backend:tick", fn)` receives events the backend pushes with `win.webContents.send(...)`.
|
|
26
|
+
* `send("selftest:done", ...)` is a one-way message handled by `ipcMain.on(...)`.
|
|
27
|
+
|
|
28
|
+
The renderer never has Node access; every call goes through the native host's capability gate.
|
|
29
|
+
|
|
30
|
+
## Self-test
|
|
31
|
+
|
|
32
|
+
With `DORMER_SELFTEST=1` the backend quits after the renderer reports the ping result and logs
|
|
33
|
+
`{"kind":"template-selftest","ok":true}`. The Dormer repository's `packages/cli/test/e2e-linux.sh`
|
|
34
|
+
uses this to verify the template headless.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"identifier": "{{identifier}}",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "A Dormer desktop app (react template)",
|
|
6
|
+
"entry": "src/main.ts",
|
|
7
|
+
"renderer": "renderer",
|
|
8
|
+
"firstWindow": { "label": "main", "width": 960, "height": 640, "title": "{{name}}", "backgroundColor": "auto" },
|
|
9
|
+
"windows": {
|
|
10
|
+
"main": { "capabilities": ["app:default"] }
|
|
11
|
+
},
|
|
12
|
+
"capabilities": {
|
|
13
|
+
"app:default": { "description": "Everything the backend exposes, for the app's own windows only", "allow": ["ipc:*", "send:*"], "origins": ["local"] }
|
|
14
|
+
},
|
|
15
|
+
"node": { "version": "24" }
|
|
16
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "dormer dev",
|
|
8
|
+
"build": "dormer build",
|
|
9
|
+
"package": "dormer package",
|
|
10
|
+
"doctor": "dormer doctor",
|
|
11
|
+
"typecheck": "tsc -p . && tsc -p renderer"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@dormer/api": "0.1.3",
|
|
15
|
+
"@dormer/renderer": "0.1.3",
|
|
16
|
+
"react": "^19.3.0",
|
|
17
|
+
"react-dom": "^19.3.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@dormer/cli": "0.1.3",
|
|
21
|
+
"@dormer/vite-plugin": "0.1.3",
|
|
22
|
+
"@types/node": "^24.0.0",
|
|
23
|
+
"@types/react": "^19.3.0",
|
|
24
|
+
"@types/react-dom": "^19.3.0",
|
|
25
|
+
"@vitejs/plugin-react": "^6.2.0",
|
|
26
|
+
"typescript": "^5.9.0",
|
|
27
|
+
"vite": "^8.3.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{name}}</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { invoke, on, send, isDormer } from "@dormer/renderer";
|
|
3
|
+
import type { PingReply } from "../../src/shared.ts";
|
|
4
|
+
|
|
5
|
+
export function App() {
|
|
6
|
+
const [reply, setReply] = useState<PingReply | null>(null);
|
|
7
|
+
const [uptime, setUptime] = useState<number | null>(null);
|
|
8
|
+
const inside = isDormer();
|
|
9
|
+
|
|
10
|
+
async function ping() {
|
|
11
|
+
// Request/response: page → host (capability check) → Node `ipcMain.handle("ping")` → back.
|
|
12
|
+
const r = await invoke<PingReply>("ping");
|
|
13
|
+
setReply(r);
|
|
14
|
+
if (r.selftest) send("selftest:done", r); // one-way message, used by the automated test
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!inside) return;
|
|
19
|
+
// Events pushed by the backend with `win.webContents.send("backend:tick", ...)`; `on` returns an unsubscribe.
|
|
20
|
+
const off = on("backend:tick", (t: { uptime: number }) => setUptime(t.uptime));
|
|
21
|
+
void ping();
|
|
22
|
+
return off;
|
|
23
|
+
}, [inside]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<main>
|
|
27
|
+
<h1>{"{{name}}"}</h1>
|
|
28
|
+
<p className="muted">React · Node.js backend · system WebView{import.meta.env.DORMER ? " · built with @dormer/vite-plugin" : ""}</p>
|
|
29
|
+
<button onClick={() => void ping()} disabled={!inside}>Ping the backend</button>
|
|
30
|
+
<pre>{inside ? (reply ? JSON.stringify(reply, null, 2) : "…") : "Not running inside Dormer. Start the app with `npm run dev`."}</pre>
|
|
31
|
+
<p className="muted">{uptime !== null ? `backend uptime: ${uptime} s` : ""}</p>
|
|
32
|
+
</main>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; }
|
|
2
|
+
body { margin: 0; }
|
|
3
|
+
main { max-width: 720px; margin: 0 auto; padding: 32px 24px; }
|
|
4
|
+
h1 { margin: 0 0 4px; font-size: 1.6rem; }
|
|
5
|
+
.muted { opacity: 0.7; margin: 0 0 16px; }
|
|
6
|
+
button { font: inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); background: color-mix(in srgb, currentColor 8%, transparent); cursor: pointer; }
|
|
7
|
+
button:hover { background: color-mix(in srgb, currentColor 16%, transparent); }
|
|
8
|
+
pre { background: color-mix(in srgb, currentColor 8%, transparent); padding: 12px; border-radius: 8px; overflow: auto; min-height: 7em; font-size: 0.85rem; }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"skipLibCheck": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// {{name}} — backend. Runs in the Node.js sidecar; the native host owns windows and WebViews.
|
|
2
|
+
import { app, BrowserWindow, ipcMain } from "@dormer/api";
|
|
3
|
+
import type { PingReply } from "./shared.ts";
|
|
4
|
+
|
|
5
|
+
const selftest = process.env.DORMER_SELFTEST === "1";
|
|
6
|
+
const log = (kind: string, payload: Record<string, unknown>) => console.log(JSON.stringify({ kind, ...payload }));
|
|
7
|
+
|
|
8
|
+
// Renderer → backend request/response: `await invoke("ping")` in the page resolves with this.
|
|
9
|
+
ipcMain.handle("ping", (): PingReply => ({ reply: "pong", node: process.version, platform: process.platform, pid: process.pid, at: Date.now(), selftest }));
|
|
10
|
+
|
|
11
|
+
// Fire-and-forget message from the renderer (`send("selftest:done", reply)`); used by the
|
|
12
|
+
// automated template test (packages/cli/test/e2e-linux.sh) when DORMER_SELFTEST=1.
|
|
13
|
+
ipcMain.on("selftest:done", (_event, result: PingReply) => {
|
|
14
|
+
if (!selftest) return;
|
|
15
|
+
const ok = result?.reply === "pong";
|
|
16
|
+
log("template-selftest", { ok, node: result?.node });
|
|
17
|
+
if (ok) app.quit(); else app.exit(1);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function createWindow(): Promise<void> {
|
|
21
|
+
const win = new BrowserWindow({ width: 960, height: 640, title: "{{name}}", show: false, capabilities: ["app:default"] });
|
|
22
|
+
win.once("ready-to-show", () => win.show());
|
|
23
|
+
// `dormer dev` serves the renderer from Vite and passes its URL; production loads dormer://app/.
|
|
24
|
+
const devUrl = process.env.DORMER_DEV_URL;
|
|
25
|
+
if (devUrl) await win.loadURL(devUrl);
|
|
26
|
+
else await win.loadApp("index.html");
|
|
27
|
+
// Backend → renderer events: received in the page with `on("backend:tick", ...)`.
|
|
28
|
+
const tick = setInterval(() => win.webContents.send("backend:tick", { uptime: Math.round(process.uptime()) }), 1000);
|
|
29
|
+
win.on("closed", () => clearInterval(tick));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); });
|
|
33
|
+
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) void createWindow(); });
|
|
34
|
+
app.whenReady().then(createWindow);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Types shared by the backend and the renderer (no Node.js imports here: the renderer's tsconfig
|
|
2
|
+
// type-checks this file too, which is what gives the page autocompletion for backend replies).
|
|
3
|
+
export interface PingReply {
|
|
4
|
+
reply: "pong";
|
|
5
|
+
node: string;
|
|
6
|
+
platform: string;
|
|
7
|
+
pid: number;
|
|
8
|
+
at: number;
|
|
9
|
+
selftest: boolean;
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"erasableSyntaxOnly": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A [Dormer](https://github.com/dormer) desktop app: a Node.js backend (`src/main.ts`) and a
|
|
4
|
+
Svelte renderer (`renderer/`) shown in the operating system's WebView.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install
|
|
8
|
+
npm run dev # Vite dev server + native window; the backend is rebuilt on change
|
|
9
|
+
npm run build # dist/renderer, dist/main.js, dist/dormer-app.json
|
|
10
|
+
npm run package # installers (needs the dormer-bundle tool)
|
|
11
|
+
npx dormer doctor # checks Node, the host binary and platform prerequisites
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Layout
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
dormer.config.json name, identifier, version, per-window capabilities, Node version pin
|
|
18
|
+
src/main.ts backend: one window, ipcMain.handle("ping"), window-all-closed → quit
|
|
19
|
+
renderer/ Vite project: index.html, vite.config.ts (@sveltejs/vite-plugin-svelte + @dormer/vite-plugin), src/App.svelte
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## How the pieces talk
|
|
23
|
+
|
|
24
|
+
* `invoke("ping")` in the renderer resolves with whatever `ipcMain.handle("ping", ...)` returns.
|
|
25
|
+
* `on("backend:tick", fn)` receives events the backend pushes with `win.webContents.send(...)`.
|
|
26
|
+
* `send("selftest:done", ...)` is a one-way message handled by `ipcMain.on(...)`.
|
|
27
|
+
|
|
28
|
+
The renderer never has Node access; every call goes through the native host's capability gate.
|
|
29
|
+
|
|
30
|
+
## Self-test
|
|
31
|
+
|
|
32
|
+
With `DORMER_SELFTEST=1` the backend quits after the renderer reports the ping result and logs
|
|
33
|
+
`{"kind":"template-selftest","ok":true}`. The Dormer repository's `packages/cli/test/e2e-linux.sh`
|
|
34
|
+
uses this to verify the template headless.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"identifier": "{{identifier}}",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "A Dormer desktop app (svelte template)",
|
|
6
|
+
"entry": "src/main.ts",
|
|
7
|
+
"renderer": "renderer",
|
|
8
|
+
"firstWindow": { "label": "main", "width": 960, "height": 640, "title": "{{name}}", "backgroundColor": "auto" },
|
|
9
|
+
"windows": {
|
|
10
|
+
"main": { "capabilities": ["app:default"] }
|
|
11
|
+
},
|
|
12
|
+
"capabilities": {
|
|
13
|
+
"app:default": { "description": "Everything the backend exposes, for the app's own windows only", "allow": ["ipc:*", "send:*"], "origins": ["local"] }
|
|
14
|
+
},
|
|
15
|
+
"node": { "version": "24" }
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "dormer dev",
|
|
8
|
+
"build": "dormer build",
|
|
9
|
+
"package": "dormer package",
|
|
10
|
+
"doctor": "dormer doctor",
|
|
11
|
+
"typecheck": "tsc -p . && svelte-check --tsconfig renderer/tsconfig.json"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@dormer/api": "0.1.3",
|
|
15
|
+
"@dormer/renderer": "0.1.3"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@dormer/cli": "0.1.3",
|
|
19
|
+
"@dormer/vite-plugin": "0.1.3",
|
|
20
|
+
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
21
|
+
"@types/node": "^24.0.0",
|
|
22
|
+
"svelte": "^5.57.0",
|
|
23
|
+
"svelte-check": "^4.0.0",
|
|
24
|
+
"typescript": "^5.9.0",
|
|
25
|
+
"vite": "^8.3.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{name}}</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/src/main.ts"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from "svelte";
|
|
3
|
+
import { invoke, on, send, isDormer } from "@dormer/renderer";
|
|
4
|
+
import type { PingReply } from "../../src/shared.ts";
|
|
5
|
+
|
|
6
|
+
const inside = isDormer();
|
|
7
|
+
let reply = $state<PingReply | null>(null);
|
|
8
|
+
let uptime = $state<number | null>(null);
|
|
9
|
+
|
|
10
|
+
async function ping() {
|
|
11
|
+
// Request/response: page → host (capability check) → Node `ipcMain.handle("ping")` → back.
|
|
12
|
+
const r = await invoke<PingReply>("ping");
|
|
13
|
+
reply = r;
|
|
14
|
+
if (r.selftest) send("selftest:done", r); // one-way message, used by the automated test
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
onMount(() => {
|
|
18
|
+
if (!inside) return;
|
|
19
|
+
// Events pushed by the backend with `win.webContents.send("backend:tick", ...)`; `on` returns an unsubscribe.
|
|
20
|
+
const off = on("backend:tick", (t: { uptime: number }) => { uptime = t.uptime; });
|
|
21
|
+
void ping();
|
|
22
|
+
return off;
|
|
23
|
+
});
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<main>
|
|
27
|
+
<h1>{{name}}</h1>
|
|
28
|
+
<p class="muted">Svelte · Node.js backend · system WebView{import.meta.env.DORMER ? " · built with @dormer/vite-plugin" : ""}</p>
|
|
29
|
+
<button disabled={!inside} onclick={() => void ping()}>Ping the backend</button>
|
|
30
|
+
<pre>{inside ? (reply ? JSON.stringify(reply, null, 2) : "…") : "Not running inside Dormer. Start the app with `npm run dev`."}</pre>
|
|
31
|
+
<p class="muted">{uptime !== null ? `backend uptime: ${uptime} s` : ""}</p>
|
|
32
|
+
</main>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5; }
|
|
2
|
+
body { margin: 0; }
|
|
3
|
+
main { max-width: 720px; margin: 0 auto; padding: 32px 24px; }
|
|
4
|
+
h1 { margin: 0 0 4px; font-size: 1.6rem; }
|
|
5
|
+
.muted { opacity: 0.7; margin: 0 0 16px; }
|
|
6
|
+
button { font: inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); background: color-mix(in srgb, currentColor 8%, transparent); cursor: pointer; }
|
|
7
|
+
button:hover { background: color-mix(in srgb, currentColor 16%, transparent); }
|
|
8
|
+
pre { background: color-mix(in srgb, currentColor 8%, transparent); padding: 12px; border-radius: 8px; overflow: auto; min-height: 7em; font-size: 0.85rem; }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"isolatedModules": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"skipLibCheck": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|