janela 0.1.2 → 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 +135 -26
  2. package/bin/janela.mjs +382 -46
  3. package/package.json +3 -3
  4. package/runtime/janela.ts +289 -122
  5. package/shim/wvshim.cc +726 -67
  6. package/templates/index.html +31 -0
  7. package/templates/main.ts +62 -19
  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/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
@@ -166,25 +310,60 @@ function ffiManifest(shimLib) {
166
310
  STR("wvInit", "wv_init"),
167
311
  STR("wvEval", "wv_eval"),
168
312
  STR("wvBind", "wv_bind"),
169
- { name: "wvReqLen", symbol: "wv_req_len", params: ["i32"], returns: "i32" },
170
- { name: "wvReqByte", symbol: "wv_req_byte", params: ["i32", "i32"], returns: "i32" },
171
- { name: "wvReplyReset", symbol: "wv_reply_reset", params: ["i32"], returns: "i32" },
172
- { name: "wvReplyPush", symbol: "wv_reply_push", params: ["i32", "i32"], returns: "i32" },
313
+ STR("wvReply", "wv_reply"),
314
+ // Retained handlers (format 4): registered once, valid for the app's
315
+ // lifetime, so wv_run is a plain blocking call. The request rides in as a
316
+ // `string` param (format 3) rather than a byte-at-a-time drain.
317
+ {
318
+ name: "wvOnInvoke", symbol: "wv_on_invoke",
319
+ params: [
320
+ "i32",
321
+ { callback: { id: "inv", params: ["string", { context: "inv" }], returns: "i32", lifetime: "retained" } },
322
+ { context: "inv" },
323
+ ],
324
+ returns: "i32",
325
+ },
173
326
  {
174
- name: "wvRun", symbol: "wv_run",
327
+ name: "wvOnTick", symbol: "wv_on_tick",
175
328
  params: [
176
329
  "i32",
177
- { callback: { id: "run", params: ["u32", "u32", { context: "run" }], returns: "i32", lifetime: "call" } },
178
- { context: "run" },
330
+ { callback: { id: "tick", params: [{ context: "tick" }], returns: "void", lifetime: "retained" } },
331
+ { context: "tick" },
179
332
  ],
180
333
  returns: "i32",
181
334
  },
335
+ { name: "wvRun", symbol: "wv_run", params: ["i32"], returns: "i32" },
182
336
  { name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
183
337
  // async: deferred returns + the UI-thread pump behind app.defer/sleep
184
338
  { name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
185
339
  { name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
186
340
  { name: "wvTickStart", symbol: "wv_tick_start", params: ["i32", "i32"], returns: "i32" },
187
341
  { name: "wvTickStop", symbol: "wv_tick_stop", params: ["i32"], returns: "i32" },
342
+ // async file I/O: the blocking syscall runs on a shim worker thread
343
+ { name: "wvFsRead", symbol: "wv_fs_read", params: ["i32", "string"], returns: "i32" },
344
+ { name: "wvFsWrite", symbol: "wv_fs_write", params: ["i32", "string", "string"], 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
+ {
349
+ name: "wvJobTake", symbol: "wv_job_take",
350
+ params: [
351
+ "i32", "i32",
352
+ { callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
353
+ { context: "sink" },
354
+ ],
355
+ returns: "i32",
356
+ },
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" },
188
367
  ];
189
368
 
190
369
  if (process.platform === "win32") {
@@ -200,12 +379,14 @@ function ffiManifest(shimLib) {
200
379
  // "undefined symbol: clock_gettime" — reproducible with a plain
201
380
  // `scriptc build hello.ts` on Windows, no FFI involved.
202
381
  return {
203
- ffi_format: 2,
382
+ ffi_format: 4,
204
383
  functions,
205
384
  libraries: [shimLib],
206
385
  system_libraries: [
207
386
  "c++", "pthread",
208
387
  "ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
388
+ // GetOpenFileNameW / GetSaveFileNameW for the native file dialogs.
389
+ "comdlg32",
209
390
  ],
210
391
  };
211
392
  }
@@ -215,7 +396,7 @@ function ffiManifest(shimLib) {
215
396
  // the link as plain input files and ld64 accepts .tbd stubs.
216
397
  const sdk = capture(["xcrun", "--sdk", "macosx", "--show-sdk-path"]);
217
398
  return {
218
- ffi_format: 2,
399
+ ffi_format: 4,
219
400
  functions,
220
401
  libraries: [
221
402
  shimLib,
@@ -226,7 +407,7 @@ function ffiManifest(shimLib) {
226
407
  };
227
408
  }
228
409
  return {
229
- ffi_format: 2,
410
+ ffi_format: 4,
230
411
  functions,
231
412
  libraries: [shimLib],
232
413
  system_libraries: [
@@ -238,9 +419,55 @@ function ffiManifest(shimLib) {
238
419
  };
239
420
  }
240
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
+
241
466
  // ---- build ----------------------------------------------------------------
242
467
 
243
- 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 } = {}) {
244
471
  const conf = loadConf(root);
245
472
  const buildDir = join(root, ".janela", "build");
246
473
  const cacheDir = join(root, ".janela", "cache");
@@ -255,12 +482,10 @@ function build(root) {
255
482
  if (!existsSync(mainSrc)) fail("missing src-host/main.ts");
256
483
  cpSync(mainSrc, join(buildDir, "main.ts"));
257
484
 
258
- const htmlSrc = join(root, "index.html");
259
- if (!existsSync(htmlSrc)) fail("missing index.html");
260
- const html = readFileSync(htmlSrc, "utf8");
485
+ const html = frontendHtml(root, conf, devUrl);
261
486
  writeFileSync(
262
487
  join(buildDir, "frontend.ts"),
263
- `// 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`,
264
489
  );
265
490
 
266
491
  const w = conf.window;
@@ -298,6 +523,14 @@ function build(root) {
298
523
  // inside the .exe rather than a side-by-side .pdb, so Windows benefits too.)
299
524
  run(["strip", bin]);
300
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
+
301
534
  if (process.platform === "darwin") {
302
535
  const bundle = join(outDir, `${conf.name}.app`);
303
536
  mkdirSync(join(bundle, "Contents", "MacOS"), { recursive: true });
@@ -330,8 +563,25 @@ function build(root) {
330
563
 
331
564
  // ---- init -----------------------------------------------------------------
332
565
 
333
- function init(name) {
334
- 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(", ")})`);
335
585
  const dir = resolve(process.cwd(), name);
336
586
  if (existsSync(dir)) fail(`${name}/ already exists`);
337
587
  mkdirSync(join(dir, "src-host"), { recursive: true });
@@ -343,44 +593,130 @@ function init(name) {
343
593
  ? `^${kitPkg.version}`
344
594
  : `file:${relative(dir, KIT) || "."}`;
345
595
 
346
- const t = (f) => readFileSync(join(KIT, "templates", f), "utf8").replaceAll("__NAME__", name);
347
- writeFileSync(join(dir, "index.html"), t("index.html"));
348
- writeFileSync(join(dir, "src-host", "main.ts"), t("main.ts"));
349
- writeFileSync(join(dir, "janela.conf.json"), t("janela.conf.json"));
350
- writeFileSync(join(dir, ".gitignore"), ".janela/\nnode_modules/\n");
351
- writeFileSync(
352
- join(dir, "package.json"),
353
- JSON.stringify(
354
- {
355
- name,
356
- private: true,
357
- scripts: { dev: "janela dev", build: "janela build" },
358
- devDependencies: { janela: janelaDep },
359
- },
360
- null,
361
- 2,
362
- ) + "\n",
363
- );
364
- 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();
365
680
  }
366
681
 
367
682
  // ---- main -----------------------------------------------------------------
368
683
 
369
- 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
+
370
706
  switch (cmd) {
371
707
  case "init":
372
- init(arg);
708
+ init(positionals()[0], flag("template", "vanilla"));
373
709
  break;
374
710
  case "build":
375
711
  build(process.cwd());
376
712
  break;
377
- case "dev": {
378
- const bin = build(process.cwd());
379
- console.log("janela: running (close the window or Ctrl-C to stop)");
380
- run([bin]);
713
+ case "dev":
714
+ await dev(process.cwd());
381
715
  break;
382
- }
383
716
  default:
384
- 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
+ );
385
721
  process.exit(cmd ? 1 : 0);
386
722
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.1.2",
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": {
@@ -8,7 +8,7 @@
8
8
  "jn": "bin/janela.mjs"
9
9
  },
10
10
  "dependencies": {
11
- "scriptc": "0.0.32"
11
+ "scriptc": "0.0.35"
12
12
  },
13
13
  "license": "MIT",
14
14
  "author": "Mateus Lage",
@@ -30,7 +30,7 @@
30
30
  "scriptc"
31
31
  ],
32
32
  "engines": {
33
- "node": ">=18"
33
+ "node": ">=24"
34
34
  },
35
35
  "files": [
36
36
  "bin/",