janela 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ janela dev # build + run with logs in the terminal
33
33
  janela build # .janela/out/my-app (+ my-app.app on macOS)
34
34
  ```
35
35
 
36
- Or start from a frontend framework — any Vite-based one:
36
+ Or start from a frontend framework:
37
37
 
38
38
  ```bash
39
39
  janela init my-app --template vue # or react | svelte | solid | vanilla
@@ -43,8 +43,18 @@ janela dev # Vite dev server + HMR, in a native windo
43
43
 
44
44
  `vanilla` is the default and needs no frontend toolchain at all. With a
45
45
  framework, `janela dev` runs your Vite dev server and points the window at it,
46
- and `janela build` flattens the production bundle into the binary — see
47
- [docs/frontend.md](../../docs/frontend.md).
46
+ and `janela build` flattens the production bundle into the binary.
47
+
48
+ All five templates are built and run on desktop, the iOS simulator and an
49
+ Android emulator — the matrix and sizes are in
50
+ [docs/frontend.md](../../docs/frontend.md). Your own Vite project works too, as
51
+ long as it produces a **single-page `dist`**: multi-entry builds, SSR/SSG
52
+ (Astro, Nuxt) and frameworks with their own non-Vite build are out of scope,
53
+ because the output is flattened into one HTML document.
54
+
55
+ Packaging for distribution — icons, a macOS `.dmg`, Android release signing, and
56
+ what requires an Apple or Google account — is in
57
+ [docs/distribution.md](../../docs/distribution.md).
48
58
 
49
59
  Requirements: Node 24+ and a C++ toolchain for the platform you are building —
50
60
  Xcode CLT on macOS; `g++` + `libwebkit2gtk-4.1-dev` on Linux; an llvm-mingw
package/bin/janela.mjs CHANGED
@@ -18,6 +18,11 @@ import { createServer } from "node:net";
18
18
  import { createRequire } from "node:module";
19
19
  import { dirname, join, relative, resolve, sep } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
+ import {
22
+ ANDROID_ABI, ANDROID_TARGET_SDK, androidConf, ffiManifest, iosConf,
23
+ libraryProfile, mimeFor, NAME_RE, patchPeSubsystem, PeError,
24
+ rewriteHostSpecifier, suggestName,
25
+ } from "./lib.mjs";
21
26
 
22
27
  const KIT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23
28
  const require = createRequire(join(KIT, "package.json"));
@@ -81,30 +86,28 @@ function viteConfigPath(root) {
81
86
  return null;
82
87
  }
83
88
 
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;
89
+ // How to run the project's own vite.
90
+ //
91
+ // The `.bin` entry is a .cmd shim on Windows and, since the CVE-2024-27980
92
+ // fix, Node refuses to spawn a .cmd without a shell. `janela dev` passed
93
+ // `shell: true` and worked; `janela build` did not, so building any Vite
94
+ // template on Windows died with `spawnSync ...\vite.cmd EINVAL`. Prefer
95
+ // vite's own JS entry run with this Node: no shim, no shell, and identical on
96
+ // every platform. The shim stays as a fallback for layouts that hide the
97
+ // package but keep the bin.
98
+ function viteCommand(root) {
99
+ const js = join(root, "node_modules", "vite", "bin", "vite.js");
100
+ if (existsSync(js)) return { argv: [process.execPath, js], shell: false };
101
+
102
+ const shim = join(root, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
103
+ if (existsSync(shim)) return { argv: [shim], shell: process.platform === "win32" };
104
+
105
+ fail(
106
+ "this project has a vite config but no local vite — run your package manager's " +
107
+ "install first (npm install / pnpm install)",
108
+ );
93
109
  }
94
110
 
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
111
 
109
112
  // A dist-relative reference ("/assets/x.js", "./assets/x.js") → absolute path,
110
113
  // or null when it points outside the build (a CDN URL, a data: URI, an anchor).
@@ -186,7 +189,8 @@ function frontendHtml(root, conf, devUrl) {
186
189
 
187
190
  if (viteConfigPath(root)) {
188
191
  console.log("janela: building the frontend with vite");
189
- run([viteBin(root), "build"], { cwd: root });
192
+ const viteCmd = viteCommand(root);
193
+ run([...viteCmd.argv, "build"], { cwd: root, shell: viteCmd.shell });
190
194
  const distDir = resolve(root, conf.frontend?.dist ?? "dist");
191
195
  const html = inlineDist(distDir);
192
196
  console.log(`janela: frontend inlined (${(Buffer.byteLength(html) / 1024).toFixed(0)} kB)`);
@@ -274,8 +278,6 @@ function webview2Include(cacheDir) {
274
278
  // Everything below is simulator-only: a device build additionally needs a
275
279
  // signing identity and a provisioning profile, which is its own project.
276
280
 
277
- const IOS_MIN_VERSION = "15.0";
278
- const IOS_PREFIX = "jl_";
279
281
 
280
282
  function iosDeviceOrFail(name) {
281
283
  const json = capture(["xcrun", "simctl", "list", "devices", "available", "--json"]);
@@ -296,76 +298,9 @@ function iosDeviceOrFail(name) {
296
298
  return pick;
297
299
  }
298
300
 
299
- function iosConf(conf) {
300
- const ios = conf.ios ?? {};
301
- return {
302
- identifier: ios.identifier ?? conf.identifier,
303
- displayName: ios.displayName ?? conf.window?.title ?? conf.name,
304
- minimumVersion: String(ios.minimumVersion ?? IOS_MIN_VERSION),
305
- device: ios.device ?? null,
306
- };
307
- }
308
-
309
301
  // The library profile: one export carries every command, so a project's own
310
302
  // commands need no ABI of their own. `janelaEmit` is the reverse channel the
311
303
  // shell registers before init.
312
- function libraryProfile() {
313
- return {
314
- profile_format: 1,
315
- name: "janela",
316
- entry: "./entry.ts",
317
- emission: "llvm",
318
- abi: {
319
- prefix: IOS_PREFIX,
320
- init_symbol: `${IOS_PREFIX}init`,
321
- sink_register_symbol: `${IOS_PREFIX}set_panic_sink`,
322
- collect_symbol: `${IOS_PREFIX}collect`,
323
- result_reset_symbol: `${IOS_PREFIX}reset`,
324
- callback_register_symbol: `${IOS_PREFIX}set_callback`,
325
- },
326
- exports: [
327
- {
328
- export: "handleInvoke",
329
- symbol: `${IOS_PREFIX}handle_invoke`,
330
- params: ["string", "string"],
331
- returns: "string",
332
- },
333
- {
334
- export: "indexHtml",
335
- symbol: `${IOS_PREFIX}index_html`,
336
- params: [],
337
- returns: "string",
338
- },
339
- // The shell calls these back on the main queue when work it owns comes
340
- // due. They are the mirror of the desktop shim's wv_on_timer: the
341
- // library parks a continuation under an id and is re-entered with it.
342
- {
343
- export: "onTimer",
344
- symbol: `${IOS_PREFIX}on_timer`,
345
- params: ["f64"],
346
- returns: "void",
347
- },
348
- {
349
- export: "onFsDone",
350
- symbol: `${IOS_PREFIX}on_fs_done`,
351
- params: ["f64", "bool", "string"],
352
- returns: "void",
353
- },
354
- ],
355
- // TS -> shell. A channel handler must never re-enter the library (see
356
- // upstream #263: violations silently appear to work), so every one of
357
- // these only records the request; the shell acts on its own queue and
358
- // re-enters through an export above on a later turn.
359
- callbacks: [
360
- { name: "janelaEmit", params: ["string", "string"], returns: "void" },
361
- { name: "hostSchedule", params: ["f64", "f64"], returns: "void" },
362
- { name: "hostSettle", params: ["f64", "string"], returns: "void" },
363
- { name: "hostReadFile", params: ["f64", "string"], returns: "void" },
364
- { name: "hostWriteFile", params: ["f64", "string", "string"], returns: "void" },
365
- ],
366
- };
367
- }
368
-
369
304
  function iosPlist(conf, iconFiles = []) {
370
305
  const ios = iosConf(conf);
371
306
  // The asset-catalogue route needs actool; the CFBundleIconFiles list is the
@@ -488,34 +423,11 @@ async function devIos(root) {
488
423
  // backend needs a companion class because android.webkit.WebView is a Java API
489
424
  // and native code cannot define a class to receive its callbacks.
490
425
 
491
- const ANDROID_MIN_SDK = 26;
492
- const ANDROID_TARGET_SDK = 34;
493
- const ANDROID_ABI = "arm64-v8a";
494
426
 
495
427
  /// Android package names are Java package names: dot-separated identifiers,
496
428
  /// so no hyphens. A janela project may be called `my-app`, which makes the
497
429
  /// default identifier `dev.janela.my-app` — legal everywhere else and not
498
430
  /// here, so each segment is coerced rather than failing the build.
499
- function androidPackage(id) {
500
- return id
501
- .split(".")
502
- .map((seg) => {
503
- const cleaned = seg.replace(/[^A-Za-z0-9_]/g, "_");
504
- return /^[A-Za-z_]/.test(cleaned) ? cleaned : `_${cleaned}`;
505
- })
506
- .join(".");
507
- }
508
-
509
- function androidConf(conf) {
510
- const a = conf.android ?? {};
511
- return {
512
- applicationId: androidPackage(a.applicationId ?? a.identifier ?? conf.identifier),
513
- label: a.label ?? conf.window?.title ?? conf.name,
514
- minSdk: String(a.minSdk ?? ANDROID_MIN_SDK),
515
- device: a.device ?? null,
516
- };
517
- }
518
-
519
431
  /// The SDK pieces an Android build needs, or a message saying which is absent.
520
432
  function androidSdk() {
521
433
  const home =
@@ -825,128 +737,6 @@ function buildShim(cacheDir) {
825
737
 
826
738
  // ---- FFI manifest ---------------------------------------------------------
827
739
 
828
- const STR = (name, symbol) => ({ name, symbol, params: ["i32", "string"], returns: "i32" });
829
-
830
- function ffiManifest(shimLib) {
831
- const functions = [
832
- { name: "wvCreate", symbol: "wv_create", params: ["i32"], returns: "i32" },
833
- STR("wvSetTitle", "wv_set_title"),
834
- { name: "wvSetSize", symbol: "wv_set_size", params: ["i32", "i32", "i32", "i32"], returns: "i32" },
835
- STR("wvSetHtml", "wv_set_html"),
836
- STR("wvInit", "wv_init"),
837
- STR("wvEval", "wv_eval"),
838
- STR("wvBind", "wv_bind"),
839
- STR("wvReply", "wv_reply"),
840
- // Retained handlers (format 4): registered once, valid for the app's
841
- // lifetime, so wv_run is a plain blocking call. The request rides in as a
842
- // `string` param (format 3) rather than a byte-at-a-time drain.
843
- {
844
- name: "wvOnInvoke", symbol: "wv_on_invoke",
845
- params: [
846
- "i32",
847
- { callback: { id: "inv", params: ["string", { context: "inv" }], returns: "i32", lifetime: "retained" } },
848
- { context: "inv" },
849
- ],
850
- returns: "i32",
851
- },
852
- {
853
- name: "wvOnTimer", symbol: "wv_on_timer",
854
- params: [
855
- "i32",
856
- { callback: { id: "timer", params: ["i32", { context: "timer" }], returns: "void", lifetime: "retained" } },
857
- { context: "timer" },
858
- ],
859
- returns: "i32",
860
- },
861
- { name: "wvRun", symbol: "wv_run", params: ["i32"], returns: "i32" },
862
- { name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
863
- // async: the held-reply table (deferred returns) plus shell-owned
864
- // scheduling — TS parks a continuation id, the shell calls it back due.
865
- { name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
866
- { name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
867
- { name: "wvSchedule", symbol: "wv_schedule", params: ["i32", "i32", "i32"], returns: "i32" },
868
- // async file I/O: the blocking syscall runs on a shim worker thread
869
- { name: "wvFsRead", symbol: "wv_fs_read", params: ["i32", "string"], returns: "i32" },
870
- { name: "wvFsWrite", symbol: "wv_fs_write", params: ["i32", "string", "string"], returns: "i32" },
871
- // Job accessors, shared by file I/O and dialogs: both are work whose
872
- // answer cannot be produced during the FFI call that starts it.
873
- { name: "wvJobStatus", symbol: "wv_job_status", params: ["i32", "i32"], returns: "i32" },
874
- { name: "wvJobSize", symbol: "wv_job_size", params: ["i32", "i32"], returns: "f64" },
875
- {
876
- // One slice per call, so a large payload decodes across several UI turns
877
- // instead of stalling on all of it at once. Returns the bytes covered.
878
- name: "wvJobTakeAt", symbol: "wv_job_take_at",
879
- params: [
880
- "i32", "i32", "f64", "f64",
881
- { callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
882
- { context: "sink" },
883
- ],
884
- returns: "f64",
885
- },
886
- { name: "wvJobFree", symbol: "wv_job_free", params: ["i32", "i32"], returns: "i32" },
887
- // Native dialogs: the modal runs on a later UI-thread turn, so asking for
888
- // one never blocks the invoke that asked. Options ride as plain params
889
- // (kind, flags, title, defaultPath, defaultName, filters).
890
- {
891
- name: "wvDialog", symbol: "wv_dialog",
892
- params: ["i32", "i32", "i32", "string", "string", "string", "string"],
893
- returns: "i32",
894
- },
895
- { name: "wvSetFullscreen", symbol: "wv_set_fullscreen", params: ["i32", "i32"], returns: "i32" },
896
- ];
897
-
898
- if (process.platform === "win32") {
899
- // MinGW ignores MSVC's #pragma comment(lib, ...), so the Win32 imports the
900
- // WebView2 backend needs are named explicitly. scriptc's own win32 lane
901
- // already adds advapi32/iphlpapi/ws2_32, so those are omitted here.
902
- // `c++` pulls libc++ for the shim's std::string/exceptions.
903
- //
904
- // `pthread` (mingw's libwinpthread) is here to work around an upstream
905
- // scriptc bug: its runtime calls clock_gettime/nanosleep, which mingw
906
- // declares in <time.h> but implements in winpthreads, and scriptc's win32
907
- // link never adds it. Without this the link dies with
908
- // "undefined symbol: clock_gettime" — reproducible with a plain
909
- // `scriptc build hello.ts` on Windows, no FFI involved.
910
- return {
911
- ffi_format: 4,
912
- functions,
913
- libraries: [shimLib],
914
- system_libraries: [
915
- "c++", "pthread",
916
- "ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
917
- // GetOpenFileNameW / GetSaveFileNameW for the native file dialogs.
918
- "comdlg32",
919
- ],
920
- };
921
- }
922
-
923
- if (process.platform === "darwin") {
924
- // scriptc has no -framework support, but `libraries` entries are passed to
925
- // the link as plain input files and ld64 accepts .tbd stubs.
926
- const sdk = capture(["xcrun", "--sdk", "macosx", "--show-sdk-path"]);
927
- return {
928
- ffi_format: 4,
929
- functions,
930
- libraries: [
931
- shimLib,
932
- join(sdk, "System/Library/Frameworks/WebKit.framework/WebKit.tbd"),
933
- join(sdk, "System/Library/Frameworks/Cocoa.framework/Cocoa.tbd"),
934
- ],
935
- system_libraries: ["c++"],
936
- };
937
- }
938
- return {
939
- ffi_format: 4,
940
- functions,
941
- libraries: [shimLib],
942
- system_libraries: [
943
- "stdc++", "webkit2gtk-4.1", "javascriptcoregtk-4.1", "gtk-3", "gdk-3",
944
- "soup-3.0", "gio-2.0", "gobject-2.0", "glib-2.0", "gmodule-2.0",
945
- "pango-1.0", "pangocairo-1.0", "harfbuzz", "atk-1.0", "cairo",
946
- "cairo-gobject", "gdk_pixbuf-2.0", "z", "pthread",
947
- ],
948
- };
949
- }
950
740
 
951
741
  // ---- Windows subsystem ------------------------------------------------------
952
742
 
@@ -962,34 +752,16 @@ function ffiManifest(shimLib) {
962
752
  // the loader whether to allocate a console. Every offset is checked before
963
753
  // anything is written, and a file that does not look like a console-subsystem
964
754
  // PE is left alone.
965
- const IMAGE_SUBSYSTEM_WINDOWS_GUI = 2;
966
- const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
967
755
 
968
756
  function makeGuiSubsystem(exePath) {
969
- const buf = readFileSync(exePath);
970
- if (buf.length < 0x40 || buf.readUInt16LE(0) !== 0x5a4d) {
971
- fail(`${exePath} is not a PE image (no MZ header)`);
757
+ let result;
758
+ try {
759
+ result = patchPeSubsystem(readFileSync(exePath));
760
+ } catch (e) {
761
+ if (e instanceof PeError) fail(`${exePath} ${e.message}`);
762
+ throw e;
972
763
  }
973
- const peOff = buf.readUInt32LE(0x3c);
974
- if (peOff + 24 > buf.length || buf.readUInt32LE(peOff) !== 0x00004550) {
975
- fail(`${exePath} has no PE signature at ${peOff}`);
976
- }
977
- // Optional header starts after the 4-byte signature and 20-byte COFF header;
978
- // Subsystem sits at +68 in both PE32 (0x10b) and PE32+ (0x20b).
979
- const optOff = peOff + 24;
980
- const magic = buf.readUInt16LE(optOff);
981
- if (magic !== 0x10b && magic !== 0x20b) {
982
- fail(`${exePath} has an unrecognised optional header magic 0x${magic.toString(16)}`);
983
- }
984
- const subOff = optOff + 68;
985
- if (subOff + 2 > buf.length) fail(`${exePath} is truncated before its Subsystem field`);
986
- const current = buf.readUInt16LE(subOff);
987
- if (current === IMAGE_SUBSYSTEM_WINDOWS_GUI) return;
988
- if (current !== IMAGE_SUBSYSTEM_WINDOWS_CUI) {
989
- fail(`${exePath} has an unexpected subsystem ${current}; refusing to rewrite it`);
990
- }
991
- buf.writeUInt16LE(IMAGE_SUBSYSTEM_WINDOWS_GUI, subOff);
992
- writeFileSync(exePath, buf);
764
+ if (result.patched) writeFileSync(exePath, result.buf);
993
765
  }
994
766
 
995
767
  // ---- build ----------------------------------------------------------------
@@ -1177,10 +949,7 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1177
949
  // as well.
1178
950
  writeFileSync(
1179
951
  join(buildDir, "main.ts"),
1180
- readFileSync(mainSrc, "utf8").replace(
1181
- /(\bfrom\s*)(['"])janela\/host\2/g,
1182
- "$1$2./janela$2",
1183
- ),
952
+ rewriteHostSpecifier(readFileSync(mainSrc, "utf8")),
1184
953
  );
1185
954
 
1186
955
  const html = frontendHtml(root, conf, devUrl);
@@ -1219,6 +988,9 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1219
988
  `/** A file job the shell owns has finished (main queue). */\n` +
1220
989
  `export function onFsDone(id: number, ok: boolean, payload: string): void {\n` +
1221
990
  ` app.onFsDone(id, ok, payload);\n` +
991
+ `}\n` +
992
+ `export function onDialogDone(id: number, ok: boolean, payload: string): void {\n` +
993
+ ` app.onDialogDone(id, ok, payload);\n` +
1222
994
  `}\n`
1223
995
  : `const app = createApp<CmdsOf<typeof setup>, EvtsOf<typeof setup>>(WINDOW);\n` +
1224
996
  `setup(app);\n` +
@@ -1248,7 +1020,12 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1248
1020
  if (ios) return buildIos(root, conf, buildDir, outDir);
1249
1021
  if (android) return buildAndroid(root, conf, buildDir, outDir);
1250
1022
 
1251
- writeFileSync(join(buildDir, "janela.ffi.json"), JSON.stringify(ffiManifest(shimLib), null, 2) + "\n");
1023
+ writeFileSync(join(buildDir, "janela.ffi.json"), JSON.stringify(ffiManifest(shimLib, {
1024
+ macSdkPath:
1025
+ process.platform === "darwin"
1026
+ ? capture(["xcrun", "--sdk", "macosx", "--show-sdk-path"])
1027
+ : null,
1028
+ }), null, 2) + "\n");
1252
1029
 
1253
1030
  console.log("janela: compiling TypeScript to a native binary");
1254
1031
  // An explicit --out is used verbatim, so the PE suffix is ours to add.
@@ -1361,20 +1138,9 @@ function copyTemplate(from, to, name) {
1361
1138
  // allowed because people type them and every downstream use accepts them —
1362
1139
  // Android application ids in particular *prefer* them, since a Java package
1363
1140
  // segment cannot contain a hyphen (see androidApplicationId).
1364
- const NAME_RE = /^[a-z][a-z0-9_-]*$/;
1365
1141
 
1366
1142
  // Best-effort repair of a rejected name, so the error can suggest something
1367
1143
  // that would have worked instead of only stating the rule.
1368
- function suggestName(raw) {
1369
- const s = String(raw)
1370
- .toLowerCase()
1371
- .replace(/[^a-z0-9_-]+/g, "-")
1372
- .replace(/^[^a-z]+/, "")
1373
- .replace(/-{2,}/g, "-")
1374
- .replace(/[-_]+$/, "");
1375
- return NAME_RE.test(s) ? s : "";
1376
- }
1377
-
1378
1144
  function init(name, template) {
1379
1145
  if (!name) {
1380
1146
  fail(
@@ -1471,11 +1237,11 @@ async function dev(root) {
1471
1237
  const port = await freePort();
1472
1238
  devUrl = `http://localhost:${port}/`;
1473
1239
  console.log(`janela: starting the vite dev server on ${devUrl}`);
1474
- vite = spawn(viteBin(root), ["--port", String(port), "--strictPort"], {
1240
+ const viteCmd = viteCommand(root);
1241
+ vite = spawn(viteCmd.argv[0], [...viteCmd.argv.slice(1), "--port", String(port), "--strictPort"], {
1475
1242
  cwd: root,
1476
1243
  stdio: "inherit",
1477
- // Windows resolves .cmd shims through the shell.
1478
- shell: process.platform === "win32",
1244
+ shell: viteCmd.shell,
1479
1245
  });
1480
1246
  const stop = () => { if (vite && vite.exitCode === null) vite.kill(); };
1481
1247
  process.on("exit", stop);