janela 0.2.0 → 0.3.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.
Files changed (38) hide show
  1. package/README.md +66 -12
  2. package/bin/janela.mjs +351 -39
  3. package/package.json +1 -1
  4. package/runtime/janela.ts +170 -25
  5. package/shim/wvshim.cc +539 -63
  6. package/templates/index.html +17 -0
  7. package/templates/main.ts +32 -0
  8. package/templates/react/deps.json +4 -0
  9. package/templates/react/files/index.html +11 -0
  10. package/templates/react/files/janela.conf.json +10 -0
  11. package/templates/react/files/src/App.css +3 -0
  12. package/templates/react/files/src/App.jsx +38 -0
  13. package/templates/react/files/src/main.jsx +9 -0
  14. package/templates/react/files/src-host/main.ts +42 -0
  15. package/templates/react/files/vite.config.js +6 -0
  16. package/templates/solid/deps.json +4 -0
  17. package/templates/solid/files/index.html +11 -0
  18. package/templates/solid/files/janela.conf.json +10 -0
  19. package/templates/solid/files/src/App.css +3 -0
  20. package/templates/solid/files/src/App.jsx +35 -0
  21. package/templates/solid/files/src/main.jsx +4 -0
  22. package/templates/solid/files/src-host/main.ts +42 -0
  23. package/templates/solid/files/vite.config.js +6 -0
  24. package/templates/svelte/deps.json +7 -0
  25. package/templates/svelte/files/index.html +11 -0
  26. package/templates/svelte/files/janela.conf.json +10 -0
  27. package/templates/svelte/files/src/App.svelte +33 -0
  28. package/templates/svelte/files/src/main.js +4 -0
  29. package/templates/svelte/files/src-host/main.ts +42 -0
  30. package/templates/svelte/files/svelte.config.js +3 -0
  31. package/templates/svelte/files/vite.config.js +6 -0
  32. package/templates/vue/deps.json +4 -0
  33. package/templates/vue/files/index.html +11 -0
  34. package/templates/vue/files/janela.conf.json +10 -0
  35. package/templates/vue/files/src/App.vue +39 -0
  36. package/templates/vue/files/src/main.js +4 -0
  37. package/templates/vue/files/src-host/main.ts +42 -0
  38. package/templates/vue/files/vite.config.js +6 -0
package/README.md CHANGED
@@ -18,6 +18,19 @@ janela dev # build + run with logs in the terminal
18
18
  janela build # .janela/out/my-app (+ my-app.app on macOS)
19
19
  ```
20
20
 
21
+ Or start from a frontend framework — any Vite-based one:
22
+
23
+ ```bash
24
+ janela init my-app --template vue # or react | svelte | solid | vanilla
25
+ cd my-app && npm install
26
+ janela dev # Vite dev server + HMR, in a native window
27
+ ```
28
+
29
+ `vanilla` is the default and needs no frontend toolchain at all. With a
30
+ framework, `janela dev` runs your Vite dev server and points the window at it,
31
+ and `janela build` flattens the production bundle into the binary — see
32
+ [docs/frontend.md](../../docs/frontend.md).
33
+
21
34
  Requirements: Node 18+, a C++ compiler (Xcode CLT on macOS; g++ +
22
35
  `libwebkit2gtk-4.1-dev` on Linux; see [Windows](#windows) below). A worked
23
36
  example lives in [`examples/demo`](examples/demo) — commands, events, and a
@@ -41,9 +54,15 @@ containing `WebView2.h`. No `WebView2Loader.dll` is needed — webview.h has its
41
54
  own loader — and end users need only the **WebView2 runtime**, which is
42
55
  preinstalled on current Windows 10/11 (it ships with Edge).
43
56
 
44
- Two caveats today: the binary is a console-subsystem app, so a console window
45
- appears behind the UI (handy for `janela dev`, wrong for shipping), and there
46
- is no installer step you get a bare `.exe`, not an MSI.
57
+ `janela build` produces a **GUI-subsystem** `.exe`, so no console window
58
+ appears behind the UI which also means `console.log` from a command has
59
+ nowhere to go. `janela dev` keeps the console subsystem, so logs are there
60
+ while you work. (scriptc exposes no way to pass `-mwindows` to the linker, so
61
+ janela rewrites the PE `Subsystem` field after linking; see
62
+ [docs/native-shell.md](../../docs/native-shell.md).)
63
+
64
+ One caveat today: there is no installer step — you get a bare `.exe`, not an
65
+ MSI.
47
66
 
48
67
  [llvm-mingw]: https://github.com/mstorsjo/llvm-mingw/releases
49
68
 
@@ -56,6 +75,10 @@ my-app/
56
75
  └── janela.conf.json name, bundle identifier, version, window
57
76
  ```
58
77
 
78
+ A Vite project adds a `vite.config.js` and a `src/` tree — that config is what
79
+ makes janela build the frontend with Vite instead of inlining `index.html`
80
+ directly.
81
+
59
82
  Frontend API (injected before page load):
60
83
 
61
84
  ```js
@@ -131,9 +154,9 @@ Errors arrive as values, never throws — `err` carries a Node-shaped message
131
154
  (`ENOENT: no such file or directory, open '/x'`). UTF-8 round-trips exactly,
132
155
  astral characters included.
133
156
 
134
- The payload still crosses the FFI boundary one byte per call (format 2), which
135
- costs about **115 ms per MB on the UI thread** — fine for config files and
136
- documents, wrong for streaming large media. See
157
+ The payload crosses in a single call (format 3), and the drain that follows it
158
+ costs about **10 ms per MB on the UI thread** — fine for config files and
159
+ documents, still worth chunking for very large media. See
137
160
  [docs/async.md](../../docs/async.md) for the measurements.
138
161
 
139
162
  **Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
@@ -152,6 +175,37 @@ The backend is ordinary TypeScript with scriptc's stdlib — including a
152
175
  `node:fs` subset — so "read a file" or "call an API" is just code in a
153
176
  command handler, no plugin layer needed.
154
177
 
178
+ ## Native dialogs and window control
179
+
180
+ ```ts
181
+ app.commandAsync("openFile", (_args, resolve) => {
182
+ app.openFileDialog(
183
+ { title: "Pick a file", filters: [{ name: "Text", extensions: ["txt", "md"] }] },
184
+ (paths, err) => {
185
+ if (paths === null) { resolve({ cancelled: true }); return; } // cancel
186
+ app.readFileAsync(paths[0], (rerr, text) => resolve({ path: paths[0], text }));
187
+ },
188
+ );
189
+ });
190
+
191
+ app.saveFileDialog({ defaultName: "untitled.txt" }, (path) => { /* … */ });
192
+
193
+ app.setTitle("new title");
194
+ app.setSize(720, 480, 0);
195
+ app.setFullscreen(true);
196
+ ```
197
+
198
+ A cancel is `null`, not an error. Options: `title`, `defaultPath`, `filters`,
199
+ plus `multiple` and `directory` for open, and `defaultName` for save.
200
+ `directory: true` is not supported on Windows and reports `ENOTSUP`.
201
+
202
+ Use `commandAsync` for dialogs — the user may take as long as they like, and
203
+ the window keeps serving other calls meanwhile. The modal itself runs on a
204
+ later UI-thread turn rather than inside the call that requests it, because a
205
+ nested modal loop would otherwise re-enter the host loop underneath a live TS
206
+ frame; [docs/native-shell.md](../../docs/native-shell.md) has the details, the
207
+ per-platform table, and the Windows GUI-subsystem note.
208
+
155
209
  ## Migrating from 0.1.x
156
210
 
157
211
  Commands used to take and return **JSON text**; they now take and return
@@ -206,12 +260,12 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
206
260
 
207
261
  ## Status
208
262
 
209
- Early proof of concept, macOS (arm64) and Linux (WebKitGTK). The design
210
- notes and scriptc findings behind it are in
211
- [docs/findings.md](docs/findings.md). Not yet: Windows, async commands
212
- that run in parallel (host code is single-threaded; `commandAsync` interleaves
213
- instead), native dialogs/tray/menus, multi-window,
214
- icons/installers/notarization.
263
+ Early proof of concept, on macOS (arm64), Linux (WebKitGTK) and Windows
264
+ (WebView2). The design notes and scriptc findings behind it are in
265
+ [docs/findings.md](../../docs/findings.md). Not yet: async commands that run in
266
+ parallel (host code is single-threaded; `commandAsync` interleaves instead),
267
+ tray icons and menus, multi-window, directory picking on Windows,
268
+ `app.center()`, and icons/installers/notarization.
215
269
 
216
270
  ## Releasing
217
271
 
package/bin/janela.mjs CHANGED
@@ -10,10 +10,11 @@
10
10
  // webview.h, the vendored webview, the scriptc runtime library, the FFI
11
11
  // manifest — lives in this package and is assembled into .janela/ at build time.
12
12
 
13
- import { spawnSync } from "node:child_process";
13
+ import { spawn, spawnSync } from "node:child_process";
14
14
  import {
15
- cpSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync,
15
+ cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync,
16
16
  } from "node:fs";
17
+ import { createServer } from "node:net";
17
18
  import { createRequire } from "node:module";
18
19
  import { dirname, join, relative, resolve, sep } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
@@ -54,6 +55,149 @@ function loadConf(root) {
54
55
  return c;
55
56
  }
56
57
 
58
+ // ---- frontend: plain HTML, or a Vite app -----------------------------------
59
+ //
60
+ // A project is in "Vite mode" when it has a vite config at its root; otherwise
61
+ // index.html is inlined verbatim, exactly as janela has always done.
62
+ //
63
+ // There is no file server behind the window: the shim hands the webview one
64
+ // HTML document (webview_set_html). So a Vite build is flattened into that one
65
+ // document — JS inlined as a module script, CSS as a <style>, everything else
66
+ // as a data: URI. The alternative designs (embedding the dist tree and serving
67
+ // it from a localhost HTTP server in the shim, or registering a custom URI
68
+ // scheme per platform) each cost a platform-specific implementation in C++ and
69
+ // buy nothing until an app outgrows a single document.
70
+
71
+ const VITE_CONFIGS = [
72
+ "vite.config.js", "vite.config.ts", "vite.config.mjs",
73
+ "vite.config.mts", "vite.config.cjs", "vite.config.cts",
74
+ ];
75
+
76
+ function viteConfigPath(root) {
77
+ for (const f of VITE_CONFIGS) {
78
+ const p = join(root, f);
79
+ if (existsSync(p)) return p;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ function viteBin(root) {
85
+ const p = join(root, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
86
+ if (!existsSync(p)) {
87
+ fail(
88
+ "this project has a vite config but no local vite — run your package manager's " +
89
+ "install first (npm install / pnpm install)",
90
+ );
91
+ }
92
+ return p;
93
+ }
94
+
95
+ const MIME = {
96
+ ".css": "text/css", ".js": "text/javascript", ".json": "application/json",
97
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
98
+ ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
99
+ ".svg": "image/svg+xml", ".ico": "image/x-icon",
100
+ ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf",
101
+ ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".webm": "video/webm",
102
+ };
103
+
104
+ function mimeFor(p) {
105
+ const dot = p.lastIndexOf(".");
106
+ return (dot < 0 ? null : MIME[p.slice(dot).toLowerCase()]) ?? "application/octet-stream";
107
+ }
108
+
109
+ // A dist-relative reference ("/assets/x.js", "./assets/x.js") → absolute path,
110
+ // or null when it points outside the build (a CDN URL, a data: URI, an anchor).
111
+ function distAsset(distDir, ref) {
112
+ if (!ref || /^(https?:|data:|blob:|#|mailto:)/i.test(ref)) return null;
113
+ const clean = ref.split("?")[0].split("#")[0].replace(/^\.?\//, "");
114
+ if (!clean) return null;
115
+ const p = join(distDir, clean);
116
+ return existsSync(p) && statSync(p).isFile() ? p : null;
117
+ }
118
+
119
+ function dataUri(file) {
120
+ return `data:${mimeFor(file)};base64,${readFileSync(file).toString("base64")}`;
121
+ }
122
+
123
+ function attr(tag, name) {
124
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i"));
125
+ return m ? m[1] : null;
126
+ }
127
+
128
+ // url(...) inside CSS → data: URIs, so a stylesheet's images and fonts survive
129
+ // the flattening too.
130
+ function inlineCssUrls(css, distDir, cssFile) {
131
+ return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (whole, _q, ref) => {
132
+ const rel = ref.startsWith("/")
133
+ ? distAsset(distDir, ref)
134
+ : distAsset(distDir, relative(distDir, join(dirname(cssFile), ref)));
135
+ return rel ? `url("${dataUri(rel)}")` : whole;
136
+ });
137
+ }
138
+
139
+ // Flatten dist/ into one self-contained HTML document.
140
+ function inlineDist(distDir) {
141
+ const htmlPath = join(distDir, "index.html");
142
+ if (!existsSync(htmlPath)) fail(`vite build produced no ${relative(process.cwd(), htmlPath)}`);
143
+ let html = readFileSync(htmlPath, "utf8");
144
+
145
+ // Preloads only matter when there are separate files to fetch.
146
+ html = html.replace(/<link\b[^>]*\brel\s*=\s*["'](?:modulepreload|preload|prefetch)["'][^>]*>\s*/gi, "");
147
+
148
+ html = html.replace(/<script\b([^>]*)><\/script>/gi, (whole, attrs) => {
149
+ const file = distAsset(distDir, attr(attrs, "src"));
150
+ if (!file) return whole;
151
+ const type = /\btype\s*=\s*["']module["']/i.test(attrs) ? ' type="module"' : "";
152
+ // A literal </script> inside the code would close this tag early.
153
+ const js = readFileSync(file, "utf8").replace(/<\/script/gi, "<\\/script");
154
+ return `<script${type}>${js}</script>`;
155
+ });
156
+
157
+ html = html.replace(/<link\b([^>]*)>/gi, (whole, attrs) => {
158
+ const href = attr(attrs, "href");
159
+ const file = distAsset(distDir, href);
160
+ if (!file) return whole;
161
+ if (/\brel\s*=\s*["']stylesheet["']/i.test(attrs)) {
162
+ const css = inlineCssUrls(readFileSync(file, "utf8"), distDir, file).replace(/<\/style/gi, "<\\/style");
163
+ return `<style>${css}</style>`;
164
+ }
165
+ return whole.replace(href, dataUri(file));
166
+ });
167
+
168
+ // Anything still pointing at a file in dist (favicons, <img>, <source>).
169
+ html = html.replace(/\b(src|href)\s*=\s*["']([^"']+)["']/gi, (whole, name, ref) => {
170
+ const file = distAsset(distDir, ref);
171
+ return file ? `${name}="${dataUri(file)}"` : whole;
172
+ });
173
+
174
+ return html;
175
+ }
176
+
177
+ // The document handed to the webview. In dev mode it is a stub that hands the
178
+ // window over to the Vite server: location.replace leaves no history entry, and
179
+ // the janela bootstrap is injected per-document (webview_init), so the served
180
+ // page gets window.janela exactly like an inlined one.
181
+ function frontendHtml(root, conf, devUrl) {
182
+ if (devUrl) {
183
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${conf.name}</title></head>` +
184
+ `<body><script>location.replace(${JSON.stringify(devUrl)});</script></body></html>`;
185
+ }
186
+
187
+ if (viteConfigPath(root)) {
188
+ console.log("janela: building the frontend with vite");
189
+ run([viteBin(root), "build"], { cwd: root });
190
+ const distDir = resolve(root, conf.frontend?.dist ?? "dist");
191
+ const html = inlineDist(distDir);
192
+ console.log(`janela: frontend inlined (${(Buffer.byteLength(html) / 1024).toFixed(0)} kB)`);
193
+ return html;
194
+ }
195
+
196
+ const htmlSrc = join(root, "index.html");
197
+ if (!existsSync(htmlSrc)) fail("missing index.html");
198
+ return readFileSync(htmlSrc, "utf8");
199
+ }
200
+
57
201
  // ---- Windows toolchain ------------------------------------------------------
58
202
 
59
203
  // scriptc's win32 lane is MinGW-shaped twice over: its runtime uses POSIX
@@ -198,9 +342,11 @@ function ffiManifest(shimLib) {
198
342
  // async file I/O: the blocking syscall runs on a shim worker thread
199
343
  { name: "wvFsRead", symbol: "wv_fs_read", params: ["i32", "string"], returns: "i32" },
200
344
  { name: "wvFsWrite", symbol: "wv_fs_write", params: ["i32", "string", "string"], returns: "i32" },
201
- { name: "wvFsStatus", symbol: "wv_fs_status", params: ["i32", "i32"], returns: "i32" },
345
+ // Job accessors, shared by file I/O and dialogs: both are work whose
346
+ // answer cannot be produced during the FFI call that starts it.
347
+ { name: "wvJobStatus", symbol: "wv_job_status", params: ["i32", "i32"], returns: "i32" },
202
348
  {
203
- name: "wvFsTake", symbol: "wv_fs_take",
349
+ name: "wvJobTake", symbol: "wv_job_take",
204
350
  params: [
205
351
  "i32", "i32",
206
352
  { callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
@@ -208,7 +354,16 @@ function ffiManifest(shimLib) {
208
354
  ],
209
355
  returns: "i32",
210
356
  },
211
- { name: "wvFsFree", symbol: "wv_fs_free", params: ["i32", "i32"], returns: "i32" },
357
+ { name: "wvJobFree", symbol: "wv_job_free", params: ["i32", "i32"], returns: "i32" },
358
+ // Native dialogs: the modal runs on a later UI-thread turn, so asking for
359
+ // one never blocks the invoke that asked. Options ride as plain params
360
+ // (kind, flags, title, defaultPath, defaultName, filters).
361
+ {
362
+ name: "wvDialog", symbol: "wv_dialog",
363
+ params: ["i32", "i32", "i32", "string", "string", "string", "string"],
364
+ returns: "i32",
365
+ },
366
+ { name: "wvSetFullscreen", symbol: "wv_set_fullscreen", params: ["i32", "i32"], returns: "i32" },
212
367
  ];
213
368
 
214
369
  if (process.platform === "win32") {
@@ -230,6 +385,8 @@ function ffiManifest(shimLib) {
230
385
  system_libraries: [
231
386
  "c++", "pthread",
232
387
  "ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
388
+ // GetOpenFileNameW / GetSaveFileNameW for the native file dialogs.
389
+ "comdlg32",
233
390
  ],
234
391
  };
235
392
  }
@@ -262,9 +419,55 @@ function ffiManifest(shimLib) {
262
419
  };
263
420
  }
264
421
 
422
+ // ---- Windows subsystem ------------------------------------------------------
423
+
424
+ // A console-subsystem .exe makes Windows open a console window behind the UI.
425
+ // The fix is normally `-mwindows` at link time, but scriptc exposes no way to
426
+ // pass a linker flag: `system_libraries` entries are validated as bare library
427
+ // names and explicitly rejected if they start with "-", `libraries` entries
428
+ // must resolve to existing files, and no env var is read for extra flags
429
+ // (checked in @scriptc/compiler 0.0.35: backend/cc.js, ffi/profile.js).
430
+ //
431
+ // So the subsystem byte is rewritten in the linked PE instead. The entry point
432
+ // is untouched — MinGW's mainCRTStartup runs either way; the field only tells
433
+ // the loader whether to allocate a console. Every offset is checked before
434
+ // anything is written, and a file that does not look like a console-subsystem
435
+ // PE is left alone.
436
+ const IMAGE_SUBSYSTEM_WINDOWS_GUI = 2;
437
+ const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
438
+
439
+ function makeGuiSubsystem(exePath) {
440
+ const buf = readFileSync(exePath);
441
+ if (buf.length < 0x40 || buf.readUInt16LE(0) !== 0x5a4d) {
442
+ fail(`${exePath} is not a PE image (no MZ header)`);
443
+ }
444
+ const peOff = buf.readUInt32LE(0x3c);
445
+ if (peOff + 24 > buf.length || buf.readUInt32LE(peOff) !== 0x00004550) {
446
+ fail(`${exePath} has no PE signature at ${peOff}`);
447
+ }
448
+ // Optional header starts after the 4-byte signature and 20-byte COFF header;
449
+ // Subsystem sits at +68 in both PE32 (0x10b) and PE32+ (0x20b).
450
+ const optOff = peOff + 24;
451
+ const magic = buf.readUInt16LE(optOff);
452
+ if (magic !== 0x10b && magic !== 0x20b) {
453
+ fail(`${exePath} has an unrecognised optional header magic 0x${magic.toString(16)}`);
454
+ }
455
+ const subOff = optOff + 68;
456
+ if (subOff + 2 > buf.length) fail(`${exePath} is truncated before its Subsystem field`);
457
+ const current = buf.readUInt16LE(subOff);
458
+ if (current === IMAGE_SUBSYSTEM_WINDOWS_GUI) return;
459
+ if (current !== IMAGE_SUBSYSTEM_WINDOWS_CUI) {
460
+ fail(`${exePath} has an unexpected subsystem ${current}; refusing to rewrite it`);
461
+ }
462
+ buf.writeUInt16LE(IMAGE_SUBSYSTEM_WINDOWS_GUI, subOff);
463
+ writeFileSync(exePath, buf);
464
+ }
465
+
265
466
  // ---- build ----------------------------------------------------------------
266
467
 
267
- function build(root) {
468
+ // `devUrl` points the window at a running vite server instead of inlining the
469
+ // frontend; `gui` asks for a GUI-subsystem .exe on Windows (build, not dev).
470
+ function build(root, { devUrl = null, gui = true } = {}) {
268
471
  const conf = loadConf(root);
269
472
  const buildDir = join(root, ".janela", "build");
270
473
  const cacheDir = join(root, ".janela", "cache");
@@ -279,12 +482,10 @@ function build(root) {
279
482
  if (!existsSync(mainSrc)) fail("missing src-host/main.ts");
280
483
  cpSync(mainSrc, join(buildDir, "main.ts"));
281
484
 
282
- const htmlSrc = join(root, "index.html");
283
- if (!existsSync(htmlSrc)) fail("missing index.html");
284
- const html = readFileSync(htmlSrc, "utf8");
485
+ const html = frontendHtml(root, conf, devUrl);
285
486
  writeFileSync(
286
487
  join(buildDir, "frontend.ts"),
287
- `// Generated by janela from index.html — do not edit.\nexport const INDEX_HTML: string = ${JSON.stringify(html)};\n`,
488
+ `// Generated by janela — do not edit.\nexport const INDEX_HTML: string = ${JSON.stringify(html)};\n`,
288
489
  );
289
490
 
290
491
  const w = conf.window;
@@ -322,6 +523,14 @@ function build(root) {
322
523
  // inside the .exe rather than a side-by-side .pdb, so Windows benefits too.)
323
524
  run(["strip", bin]);
324
525
 
526
+ // `janela dev` keeps the console subsystem so console.log from a command is
527
+ // visible in the terminal; a shipped `janela build` must not flash a console
528
+ // window behind the UI, and loses stdout as the price.
529
+ if (process.platform === "win32" && gui) {
530
+ makeGuiSubsystem(bin);
531
+ console.log("janela: linked as a GUI-subsystem .exe (no console window)");
532
+ }
533
+
325
534
  if (process.platform === "darwin") {
326
535
  const bundle = join(outDir, `${conf.name}.app`);
327
536
  mkdirSync(join(bundle, "Contents", "MacOS"), { recursive: true });
@@ -354,8 +563,25 @@ function build(root) {
354
563
 
355
564
  // ---- init -----------------------------------------------------------------
356
565
 
357
- function init(name) {
358
- if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) fail("usage: janela init <name> (lowercase, digits, dashes)");
566
+ const TEMPLATES = ["vanilla", "vue", "react", "svelte", "solid"];
567
+
568
+ // Copy a template tree, substituting the project name in text files.
569
+ function copyTemplate(from, to, name) {
570
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
571
+ const src = join(from, entry.name);
572
+ const dst = join(to, entry.name);
573
+ if (entry.isDirectory()) {
574
+ mkdirSync(dst, { recursive: true });
575
+ copyTemplate(src, dst, name);
576
+ } else {
577
+ writeFileSync(dst, readFileSync(src, "utf8").replaceAll("__NAME__", name));
578
+ }
579
+ }
580
+ }
581
+
582
+ function init(name, template) {
583
+ if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) fail("usage: janela init <name> [--template <t>] (lowercase, digits, dashes)");
584
+ if (!TEMPLATES.includes(template)) fail(`unknown template '${template}' (${TEMPLATES.join(", ")})`);
359
585
  const dir = resolve(process.cwd(), name);
360
586
  if (existsSync(dir)) fail(`${name}/ already exists`);
361
587
  mkdirSync(join(dir, "src-host"), { recursive: true });
@@ -367,44 +593,130 @@ function init(name) {
367
593
  ? `^${kitPkg.version}`
368
594
  : `file:${relative(dir, KIT) || "."}`;
369
595
 
370
- const t = (f) => readFileSync(join(KIT, "templates", f), "utf8").replaceAll("__NAME__", name);
371
- writeFileSync(join(dir, "index.html"), t("index.html"));
372
- writeFileSync(join(dir, "src-host", "main.ts"), t("main.ts"));
373
- writeFileSync(join(dir, "janela.conf.json"), t("janela.conf.json"));
374
- writeFileSync(join(dir, ".gitignore"), ".janela/\nnode_modules/\n");
375
- writeFileSync(
376
- join(dir, "package.json"),
377
- JSON.stringify(
378
- {
379
- name,
380
- private: true,
381
- scripts: { dev: "janela dev", build: "janela build" },
382
- devDependencies: { janela: janelaDep },
383
- },
384
- null,
385
- 2,
386
- ) + "\n",
387
- );
388
- console.log(`janela: created ${name}/ — next: cd ${name} && janela dev`);
596
+ const pkg = {
597
+ name,
598
+ private: true,
599
+ scripts: { dev: "janela dev", build: "janela build" },
600
+ devDependencies: { janela: janelaDep },
601
+ };
602
+
603
+ if (template === "vanilla") {
604
+ // The zero-dependency shape janela has always scaffolded: no frontend
605
+ // toolchain, no npm install needed before the first build.
606
+ const t = (f) => readFileSync(join(KIT, "templates", f), "utf8").replaceAll("__NAME__", name);
607
+ writeFileSync(join(dir, "index.html"), t("index.html"));
608
+ writeFileSync(join(dir, "src-host", "main.ts"), t("main.ts"));
609
+ writeFileSync(join(dir, "janela.conf.json"), t("janela.conf.json"));
610
+ } else {
611
+ const tdir = join(KIT, "templates", template);
612
+ copyTemplate(join(tdir, "files"), dir, name);
613
+ const extra = JSON.parse(readFileSync(join(tdir, "deps.json"), "utf8"));
614
+ pkg.type = "module";
615
+ Object.assign(pkg.devDependencies, extra.devDependencies ?? {});
616
+ if (extra.dependencies) pkg.dependencies = extra.dependencies;
617
+ }
618
+
619
+ writeFileSync(join(dir, ".gitignore"), ".janela/\nnode_modules/\ndist/\n");
620
+ writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
621
+
622
+ const install = template === "vanilla" ? "" : "npm install && ";
623
+ console.log(`janela: created ${name}/ (${template}) — next: cd ${name} && ${install}janela dev`);
624
+ }
625
+
626
+ // ---- dev --------------------------------------------------------------------
627
+
628
+ function freePort() {
629
+ return new Promise((ok, no) => {
630
+ const s = createServer();
631
+ s.on("error", no);
632
+ s.listen(0, "127.0.0.1", () => {
633
+ const { port } = s.address();
634
+ s.close(() => ok(port));
635
+ });
636
+ });
637
+ }
638
+
639
+ async function waitForServer(url, child, timeoutMs = 30000) {
640
+ const deadline = Date.now() + timeoutMs;
641
+ while (Date.now() < deadline) {
642
+ if (child.exitCode !== null) fail(`the vite dev server exited (code ${child.exitCode})`);
643
+ try {
644
+ const r = await fetch(url, { signal: AbortSignal.timeout(1000) });
645
+ if (r.ok || r.status === 404) return;
646
+ } catch {
647
+ // not listening yet
648
+ }
649
+ await new Promise((ok) => setTimeout(ok, 150));
650
+ }
651
+ fail(`the vite dev server did not answer at ${url} within ${timeoutMs / 1000}s`);
652
+ }
653
+
654
+ async function dev(root) {
655
+ let vite = null;
656
+ let devUrl = null;
657
+
658
+ if (viteConfigPath(root)) {
659
+ const port = await freePort();
660
+ devUrl = `http://localhost:${port}/`;
661
+ console.log(`janela: starting the vite dev server on ${devUrl}`);
662
+ vite = spawn(viteBin(root), ["--port", String(port), "--strictPort"], {
663
+ cwd: root,
664
+ stdio: "inherit",
665
+ // Windows resolves .cmd shims through the shell.
666
+ shell: process.platform === "win32",
667
+ });
668
+ const stop = () => { if (vite && vite.exitCode === null) vite.kill(); };
669
+ process.on("exit", stop);
670
+ process.on("SIGINT", () => { stop(); process.exit(130); });
671
+ await waitForServer(devUrl, vite);
672
+ }
673
+
674
+ // Console subsystem on Windows: dev is where you want the logs.
675
+ const bin = build(root, { devUrl, gui: false });
676
+ console.log("janela: running (close the window or Ctrl-C to stop)");
677
+ // The frontend hot-reloads through vite; host changes need another `dev`.
678
+ run([bin]);
679
+ if (vite && vite.exitCode === null) vite.kill();
389
680
  }
390
681
 
391
682
  // ---- main -----------------------------------------------------------------
392
683
 
393
- const [cmd, arg] = process.argv.slice(2);
684
+ const argv = process.argv.slice(2);
685
+ const cmd = argv[0];
686
+
687
+ function flag(name, fallback) {
688
+ const eq = argv.find((a) => a.startsWith(`--${name}=`));
689
+ if (eq) return eq.slice(name.length + 3);
690
+ const i = argv.indexOf(`--${name}`);
691
+ if (i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--")) return argv[i + 1];
692
+ return fallback;
693
+ }
694
+
695
+ // Everything after the subcommand that is neither a flag nor a flag's value.
696
+ function positionals() {
697
+ const out = [];
698
+ for (let i = 1; i < argv.length; i++) {
699
+ const a = argv[i];
700
+ if (!a.startsWith("--")) { out.push(a); continue; }
701
+ if (!a.includes("=") && argv[i + 1] && !argv[i + 1].startsWith("--")) i++;
702
+ }
703
+ return out;
704
+ }
705
+
394
706
  switch (cmd) {
395
707
  case "init":
396
- init(arg);
708
+ init(positionals()[0], flag("template", "vanilla"));
397
709
  break;
398
710
  case "build":
399
711
  build(process.cwd());
400
712
  break;
401
- case "dev": {
402
- const bin = build(process.cwd());
403
- console.log("janela: running (close the window or Ctrl-C to stop)");
404
- run([bin]);
713
+ case "dev":
714
+ await dev(process.cwd());
405
715
  break;
406
- }
407
716
  default:
408
- console.log("usage: janela init <name> | janela build | janela dev");
717
+ console.log(
718
+ "usage: janela init <name> [--template vanilla|vue|react|svelte|solid]\n" +
719
+ " janela build | janela dev",
720
+ );
409
721
  process.exit(cmd ? 1 : 0);
410
722
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Desktop apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {