janela 0.2.0 → 0.3.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.
Files changed (38) hide show
  1. package/README.md +71 -13
  2. package/bin/janela.mjs +355 -40
  3. package/package.json +1 -1
  4. package/runtime/janela.ts +280 -37
  5. package/shim/wvshim.cc +593 -70
  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,10 +154,14 @@ 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
137
- [docs/async.md](../../docs/async.md) for the measurements.
157
+ The payload crosses in a single call (format 3), and the decode that follows is
158
+ spread across turns under a 4 ms budget, so a large read no longer stalls the
159
+ window: a 100 MB file's worst UI pause is ~25 ms (p99 4 ms) rather than ~176 ms,
160
+ at the same throughput. The remaining pause is the one unavoidable copy that
161
+ materialises the string for your callback. See
162
+ [docs/async.md](../../docs/async.md) for the measurements — and note that
163
+ indexing a large string in your own callback (`text.length`, `slice`) is O(n)
164
+ in scriptc and can cost far more than the read did.
138
165
 
139
166
  **Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
140
167
  as long as the program sits inside the `run()` FFI call, so `setTimeout`,
@@ -152,6 +179,37 @@ The backend is ordinary TypeScript with scriptc's stdlib — including a
152
179
  `node:fs` subset — so "read a file" or "call an API" is just code in a
153
180
  command handler, no plugin layer needed.
154
181
 
182
+ ## Native dialogs and window control
183
+
184
+ ```ts
185
+ app.commandAsync("openFile", (_args, resolve) => {
186
+ app.openFileDialog(
187
+ { title: "Pick a file", filters: [{ name: "Text", extensions: ["txt", "md"] }] },
188
+ (paths, err) => {
189
+ if (paths === null) { resolve({ cancelled: true }); return; } // cancel
190
+ app.readFileAsync(paths[0], (rerr, text) => resolve({ path: paths[0], text }));
191
+ },
192
+ );
193
+ });
194
+
195
+ app.saveFileDialog({ defaultName: "untitled.txt" }, (path) => { /* … */ });
196
+
197
+ app.setTitle("new title");
198
+ app.setSize(720, 480, 0);
199
+ app.setFullscreen(true);
200
+ ```
201
+
202
+ A cancel is `null`, not an error. Options: `title`, `defaultPath`, `filters`,
203
+ plus `multiple` and `directory` for open, and `defaultName` for save.
204
+ `directory: true` is not supported on Windows and reports `ENOTSUP`.
205
+
206
+ Use `commandAsync` for dialogs — the user may take as long as they like, and
207
+ the window keeps serving other calls meanwhile. The modal itself runs on a
208
+ later UI-thread turn rather than inside the call that requests it, because a
209
+ nested modal loop would otherwise re-enter the host loop underneath a live TS
210
+ frame; [docs/native-shell.md](../../docs/native-shell.md) has the details, the
211
+ per-platform table, and the Windows GUI-subsystem note.
212
+
155
213
  ## Migrating from 0.1.x
156
214
 
157
215
  Commands used to take and return **JSON text**; they now take and return
@@ -206,12 +264,12 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
206
264
 
207
265
  ## Status
208
266
 
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.
267
+ Early proof of concept, on macOS (arm64), Linux (WebKitGTK) and Windows
268
+ (WebView2). The design notes and scriptc findings behind it are in
269
+ [docs/findings.md](../../docs/findings.md). Not yet: async commands that run in
270
+ parallel (host code is single-threaded; `commandAsync` interleaves instead),
271
+ tray icons and menus, multi-window, directory picking on Windows,
272
+ `app.center()`, and icons/installers/notarization.
215
273
 
216
274
  ## Releasing
217
275
 
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,17 +342,31 @@ 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" },
348
+ { name: "wvJobSize", symbol: "wv_job_size", params: ["i32", "i32"], returns: "f64" },
202
349
  {
203
- name: "wvFsTake", symbol: "wv_fs_take",
350
+ // One slice per call, so a large payload decodes across several UI turns
351
+ // instead of stalling on all of it at once. Returns the bytes covered.
352
+ name: "wvJobTakeAt", symbol: "wv_job_take_at",
204
353
  params: [
205
- "i32", "i32",
354
+ "i32", "i32", "f64", "f64",
206
355
  { callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
207
356
  { context: "sink" },
208
357
  ],
358
+ returns: "f64",
359
+ },
360
+ { name: "wvJobFree", symbol: "wv_job_free", params: ["i32", "i32"], returns: "i32" },
361
+ // Native dialogs: the modal runs on a later UI-thread turn, so asking for
362
+ // one never blocks the invoke that asked. Options ride as plain params
363
+ // (kind, flags, title, defaultPath, defaultName, filters).
364
+ {
365
+ name: "wvDialog", symbol: "wv_dialog",
366
+ params: ["i32", "i32", "i32", "string", "string", "string", "string"],
209
367
  returns: "i32",
210
368
  },
211
- { name: "wvFsFree", symbol: "wv_fs_free", params: ["i32", "i32"], returns: "i32" },
369
+ { name: "wvSetFullscreen", symbol: "wv_set_fullscreen", params: ["i32", "i32"], returns: "i32" },
212
370
  ];
213
371
 
214
372
  if (process.platform === "win32") {
@@ -230,6 +388,8 @@ function ffiManifest(shimLib) {
230
388
  system_libraries: [
231
389
  "c++", "pthread",
232
390
  "ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
391
+ // GetOpenFileNameW / GetSaveFileNameW for the native file dialogs.
392
+ "comdlg32",
233
393
  ],
234
394
  };
235
395
  }
@@ -262,9 +422,55 @@ function ffiManifest(shimLib) {
262
422
  };
263
423
  }
264
424
 
425
+ // ---- Windows subsystem ------------------------------------------------------
426
+
427
+ // A console-subsystem .exe makes Windows open a console window behind the UI.
428
+ // The fix is normally `-mwindows` at link time, but scriptc exposes no way to
429
+ // pass a linker flag: `system_libraries` entries are validated as bare library
430
+ // names and explicitly rejected if they start with "-", `libraries` entries
431
+ // must resolve to existing files, and no env var is read for extra flags
432
+ // (checked in @scriptc/compiler 0.0.35: backend/cc.js, ffi/profile.js).
433
+ //
434
+ // So the subsystem byte is rewritten in the linked PE instead. The entry point
435
+ // is untouched — MinGW's mainCRTStartup runs either way; the field only tells
436
+ // the loader whether to allocate a console. Every offset is checked before
437
+ // anything is written, and a file that does not look like a console-subsystem
438
+ // PE is left alone.
439
+ const IMAGE_SUBSYSTEM_WINDOWS_GUI = 2;
440
+ const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
441
+
442
+ function makeGuiSubsystem(exePath) {
443
+ const buf = readFileSync(exePath);
444
+ if (buf.length < 0x40 || buf.readUInt16LE(0) !== 0x5a4d) {
445
+ fail(`${exePath} is not a PE image (no MZ header)`);
446
+ }
447
+ const peOff = buf.readUInt32LE(0x3c);
448
+ if (peOff + 24 > buf.length || buf.readUInt32LE(peOff) !== 0x00004550) {
449
+ fail(`${exePath} has no PE signature at ${peOff}`);
450
+ }
451
+ // Optional header starts after the 4-byte signature and 20-byte COFF header;
452
+ // Subsystem sits at +68 in both PE32 (0x10b) and PE32+ (0x20b).
453
+ const optOff = peOff + 24;
454
+ const magic = buf.readUInt16LE(optOff);
455
+ if (magic !== 0x10b && magic !== 0x20b) {
456
+ fail(`${exePath} has an unrecognised optional header magic 0x${magic.toString(16)}`);
457
+ }
458
+ const subOff = optOff + 68;
459
+ if (subOff + 2 > buf.length) fail(`${exePath} is truncated before its Subsystem field`);
460
+ const current = buf.readUInt16LE(subOff);
461
+ if (current === IMAGE_SUBSYSTEM_WINDOWS_GUI) return;
462
+ if (current !== IMAGE_SUBSYSTEM_WINDOWS_CUI) {
463
+ fail(`${exePath} has an unexpected subsystem ${current}; refusing to rewrite it`);
464
+ }
465
+ buf.writeUInt16LE(IMAGE_SUBSYSTEM_WINDOWS_GUI, subOff);
466
+ writeFileSync(exePath, buf);
467
+ }
468
+
265
469
  // ---- build ----------------------------------------------------------------
266
470
 
267
- function build(root) {
471
+ // `devUrl` points the window at a running vite server instead of inlining the
472
+ // frontend; `gui` asks for a GUI-subsystem .exe on Windows (build, not dev).
473
+ function build(root, { devUrl = null, gui = true } = {}) {
268
474
  const conf = loadConf(root);
269
475
  const buildDir = join(root, ".janela", "build");
270
476
  const cacheDir = join(root, ".janela", "cache");
@@ -279,12 +485,10 @@ function build(root) {
279
485
  if (!existsSync(mainSrc)) fail("missing src-host/main.ts");
280
486
  cpSync(mainSrc, join(buildDir, "main.ts"));
281
487
 
282
- const htmlSrc = join(root, "index.html");
283
- if (!existsSync(htmlSrc)) fail("missing index.html");
284
- const html = readFileSync(htmlSrc, "utf8");
488
+ const html = frontendHtml(root, conf, devUrl);
285
489
  writeFileSync(
286
490
  join(buildDir, "frontend.ts"),
287
- `// Generated by janela from index.html — do not edit.\nexport const INDEX_HTML: string = ${JSON.stringify(html)};\n`,
491
+ `// Generated by janela — do not edit.\nexport const INDEX_HTML: string = ${JSON.stringify(html)};\n`,
288
492
  );
289
493
 
290
494
  const w = conf.window;
@@ -322,6 +526,14 @@ function build(root) {
322
526
  // inside the .exe rather than a side-by-side .pdb, so Windows benefits too.)
323
527
  run(["strip", bin]);
324
528
 
529
+ // `janela dev` keeps the console subsystem so console.log from a command is
530
+ // visible in the terminal; a shipped `janela build` must not flash a console
531
+ // window behind the UI, and loses stdout as the price.
532
+ if (process.platform === "win32" && gui) {
533
+ makeGuiSubsystem(bin);
534
+ console.log("janela: linked as a GUI-subsystem .exe (no console window)");
535
+ }
536
+
325
537
  if (process.platform === "darwin") {
326
538
  const bundle = join(outDir, `${conf.name}.app`);
327
539
  mkdirSync(join(bundle, "Contents", "MacOS"), { recursive: true });
@@ -354,8 +566,25 @@ function build(root) {
354
566
 
355
567
  // ---- init -----------------------------------------------------------------
356
568
 
357
- function init(name) {
358
- if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) fail("usage: janela init <name> (lowercase, digits, dashes)");
569
+ const TEMPLATES = ["vanilla", "vue", "react", "svelte", "solid"];
570
+
571
+ // Copy a template tree, substituting the project name in text files.
572
+ function copyTemplate(from, to, name) {
573
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
574
+ const src = join(from, entry.name);
575
+ const dst = join(to, entry.name);
576
+ if (entry.isDirectory()) {
577
+ mkdirSync(dst, { recursive: true });
578
+ copyTemplate(src, dst, name);
579
+ } else {
580
+ writeFileSync(dst, readFileSync(src, "utf8").replaceAll("__NAME__", name));
581
+ }
582
+ }
583
+ }
584
+
585
+ function init(name, template) {
586
+ if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) fail("usage: janela init <name> [--template <t>] (lowercase, digits, dashes)");
587
+ if (!TEMPLATES.includes(template)) fail(`unknown template '${template}' (${TEMPLATES.join(", ")})`);
359
588
  const dir = resolve(process.cwd(), name);
360
589
  if (existsSync(dir)) fail(`${name}/ already exists`);
361
590
  mkdirSync(join(dir, "src-host"), { recursive: true });
@@ -367,44 +596,130 @@ function init(name) {
367
596
  ? `^${kitPkg.version}`
368
597
  : `file:${relative(dir, KIT) || "."}`;
369
598
 
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`);
599
+ const pkg = {
600
+ name,
601
+ private: true,
602
+ scripts: { dev: "janela dev", build: "janela build" },
603
+ devDependencies: { janela: janelaDep },
604
+ };
605
+
606
+ if (template === "vanilla") {
607
+ // The zero-dependency shape janela has always scaffolded: no frontend
608
+ // toolchain, no npm install needed before the first build.
609
+ const t = (f) => readFileSync(join(KIT, "templates", f), "utf8").replaceAll("__NAME__", name);
610
+ writeFileSync(join(dir, "index.html"), t("index.html"));
611
+ writeFileSync(join(dir, "src-host", "main.ts"), t("main.ts"));
612
+ writeFileSync(join(dir, "janela.conf.json"), t("janela.conf.json"));
613
+ } else {
614
+ const tdir = join(KIT, "templates", template);
615
+ copyTemplate(join(tdir, "files"), dir, name);
616
+ const extra = JSON.parse(readFileSync(join(tdir, "deps.json"), "utf8"));
617
+ pkg.type = "module";
618
+ Object.assign(pkg.devDependencies, extra.devDependencies ?? {});
619
+ if (extra.dependencies) pkg.dependencies = extra.dependencies;
620
+ }
621
+
622
+ writeFileSync(join(dir, ".gitignore"), ".janela/\nnode_modules/\ndist/\n");
623
+ writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
624
+
625
+ const install = template === "vanilla" ? "" : "npm install && ";
626
+ console.log(`janela: created ${name}/ (${template}) — next: cd ${name} && ${install}janela dev`);
627
+ }
628
+
629
+ // ---- dev --------------------------------------------------------------------
630
+
631
+ function freePort() {
632
+ return new Promise((ok, no) => {
633
+ const s = createServer();
634
+ s.on("error", no);
635
+ s.listen(0, "127.0.0.1", () => {
636
+ const { port } = s.address();
637
+ s.close(() => ok(port));
638
+ });
639
+ });
640
+ }
641
+
642
+ async function waitForServer(url, child, timeoutMs = 30000) {
643
+ const deadline = Date.now() + timeoutMs;
644
+ while (Date.now() < deadline) {
645
+ if (child.exitCode !== null) fail(`the vite dev server exited (code ${child.exitCode})`);
646
+ try {
647
+ const r = await fetch(url, { signal: AbortSignal.timeout(1000) });
648
+ if (r.ok || r.status === 404) return;
649
+ } catch {
650
+ // not listening yet
651
+ }
652
+ await new Promise((ok) => setTimeout(ok, 150));
653
+ }
654
+ fail(`the vite dev server did not answer at ${url} within ${timeoutMs / 1000}s`);
655
+ }
656
+
657
+ async function dev(root) {
658
+ let vite = null;
659
+ let devUrl = null;
660
+
661
+ if (viteConfigPath(root)) {
662
+ const port = await freePort();
663
+ devUrl = `http://localhost:${port}/`;
664
+ console.log(`janela: starting the vite dev server on ${devUrl}`);
665
+ vite = spawn(viteBin(root), ["--port", String(port), "--strictPort"], {
666
+ cwd: root,
667
+ stdio: "inherit",
668
+ // Windows resolves .cmd shims through the shell.
669
+ shell: process.platform === "win32",
670
+ });
671
+ const stop = () => { if (vite && vite.exitCode === null) vite.kill(); };
672
+ process.on("exit", stop);
673
+ process.on("SIGINT", () => { stop(); process.exit(130); });
674
+ await waitForServer(devUrl, vite);
675
+ }
676
+
677
+ // Console subsystem on Windows: dev is where you want the logs.
678
+ const bin = build(root, { devUrl, gui: false });
679
+ console.log("janela: running (close the window or Ctrl-C to stop)");
680
+ // The frontend hot-reloads through vite; host changes need another `dev`.
681
+ run([bin]);
682
+ if (vite && vite.exitCode === null) vite.kill();
389
683
  }
390
684
 
391
685
  // ---- main -----------------------------------------------------------------
392
686
 
393
- const [cmd, arg] = process.argv.slice(2);
687
+ const argv = process.argv.slice(2);
688
+ const cmd = argv[0];
689
+
690
+ function flag(name, fallback) {
691
+ const eq = argv.find((a) => a.startsWith(`--${name}=`));
692
+ if (eq) return eq.slice(name.length + 3);
693
+ const i = argv.indexOf(`--${name}`);
694
+ if (i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--")) return argv[i + 1];
695
+ return fallback;
696
+ }
697
+
698
+ // Everything after the subcommand that is neither a flag nor a flag's value.
699
+ function positionals() {
700
+ const out = [];
701
+ for (let i = 1; i < argv.length; i++) {
702
+ const a = argv[i];
703
+ if (!a.startsWith("--")) { out.push(a); continue; }
704
+ if (!a.includes("=") && argv[i + 1] && !argv[i + 1].startsWith("--")) i++;
705
+ }
706
+ return out;
707
+ }
708
+
394
709
  switch (cmd) {
395
710
  case "init":
396
- init(arg);
711
+ init(positionals()[0], flag("template", "vanilla"));
397
712
  break;
398
713
  case "build":
399
714
  build(process.cwd());
400
715
  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]);
716
+ case "dev":
717
+ await dev(process.cwd());
405
718
  break;
406
- }
407
719
  default:
408
- console.log("usage: janela init <name> | janela build | janela dev");
720
+ console.log(
721
+ "usage: janela init <name> [--template vanilla|vue|react|svelte|solid]\n" +
722
+ " janela build | janela dev",
723
+ );
409
724
  process.exit(cmd ? 1 : 0);
410
725
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Desktop apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {