janela 0.13.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/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,86 +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
- // Deliberately its own export rather than reusing onFsDone, whose
355
- // signature would fit: a dialog result arriving through the file-I/O
356
- // path would read as a bug for as long as the code lived.
357
- {
358
- export: "onDialogDone",
359
- symbol: `${IOS_PREFIX}on_dialog_done`,
360
- params: ["f64", "bool", "string"],
361
- returns: "void",
362
- },
363
- ],
364
- // TS -> shell. A channel handler must never re-enter the library (see
365
- // upstream #263: violations silently appear to work), so every one of
366
- // these only records the request; the shell acts on its own queue and
367
- // re-enters through an export above on a later turn.
368
- callbacks: [
369
- { name: "janelaEmit", params: ["string", "string"], returns: "void" },
370
- { name: "hostSchedule", params: ["f64", "f64"], returns: "void" },
371
- { name: "hostSettle", params: ["f64", "string"], returns: "void" },
372
- { name: "hostReadFile", params: ["f64", "string"], returns: "void" },
373
- { name: "hostWriteFile", params: ["f64", "string", "string"], returns: "void" },
374
- { name: "hostOpenDialog", params: ["f64", "string"], returns: "void" },
375
- ],
376
- };
377
- }
378
-
379
304
  function iosPlist(conf, iconFiles = []) {
380
305
  const ios = iosConf(conf);
381
306
  // The asset-catalogue route needs actool; the CFBundleIconFiles list is the
@@ -498,34 +423,11 @@ async function devIos(root) {
498
423
  // backend needs a companion class because android.webkit.WebView is a Java API
499
424
  // and native code cannot define a class to receive its callbacks.
500
425
 
501
- const ANDROID_MIN_SDK = 26;
502
- const ANDROID_TARGET_SDK = 34;
503
- const ANDROID_ABI = "arm64-v8a";
504
426
 
505
427
  /// Android package names are Java package names: dot-separated identifiers,
506
428
  /// so no hyphens. A janela project may be called `my-app`, which makes the
507
429
  /// default identifier `dev.janela.my-app` — legal everywhere else and not
508
430
  /// here, so each segment is coerced rather than failing the build.
509
- function androidPackage(id) {
510
- return id
511
- .split(".")
512
- .map((seg) => {
513
- const cleaned = seg.replace(/[^A-Za-z0-9_]/g, "_");
514
- return /^[A-Za-z_]/.test(cleaned) ? cleaned : `_${cleaned}`;
515
- })
516
- .join(".");
517
- }
518
-
519
- function androidConf(conf) {
520
- const a = conf.android ?? {};
521
- return {
522
- applicationId: androidPackage(a.applicationId ?? a.identifier ?? conf.identifier),
523
- label: a.label ?? conf.window?.title ?? conf.name,
524
- minSdk: String(a.minSdk ?? ANDROID_MIN_SDK),
525
- device: a.device ?? null,
526
- };
527
- }
528
-
529
431
  /// The SDK pieces an Android build needs, or a message saying which is absent.
530
432
  function androidSdk() {
531
433
  const home =
@@ -835,128 +737,6 @@ function buildShim(cacheDir) {
835
737
 
836
738
  // ---- FFI manifest ---------------------------------------------------------
837
739
 
838
- const STR = (name, symbol) => ({ name, symbol, params: ["i32", "string"], returns: "i32" });
839
-
840
- function ffiManifest(shimLib) {
841
- const functions = [
842
- { name: "wvCreate", symbol: "wv_create", params: ["i32"], returns: "i32" },
843
- STR("wvSetTitle", "wv_set_title"),
844
- { name: "wvSetSize", symbol: "wv_set_size", params: ["i32", "i32", "i32", "i32"], returns: "i32" },
845
- STR("wvSetHtml", "wv_set_html"),
846
- STR("wvInit", "wv_init"),
847
- STR("wvEval", "wv_eval"),
848
- STR("wvBind", "wv_bind"),
849
- STR("wvReply", "wv_reply"),
850
- // Retained handlers (format 4): registered once, valid for the app's
851
- // lifetime, so wv_run is a plain blocking call. The request rides in as a
852
- // `string` param (format 3) rather than a byte-at-a-time drain.
853
- {
854
- name: "wvOnInvoke", symbol: "wv_on_invoke",
855
- params: [
856
- "i32",
857
- { callback: { id: "inv", params: ["string", { context: "inv" }], returns: "i32", lifetime: "retained" } },
858
- { context: "inv" },
859
- ],
860
- returns: "i32",
861
- },
862
- {
863
- name: "wvOnTimer", symbol: "wv_on_timer",
864
- params: [
865
- "i32",
866
- { callback: { id: "timer", params: ["i32", { context: "timer" }], returns: "void", lifetime: "retained" } },
867
- { context: "timer" },
868
- ],
869
- returns: "i32",
870
- },
871
- { name: "wvRun", symbol: "wv_run", params: ["i32"], returns: "i32" },
872
- { name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
873
- // async: the held-reply table (deferred returns) plus shell-owned
874
- // scheduling — TS parks a continuation id, the shell calls it back due.
875
- { name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
876
- { name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
877
- { name: "wvSchedule", symbol: "wv_schedule", params: ["i32", "i32", "i32"], returns: "i32" },
878
- // async file I/O: the blocking syscall runs on a shim worker thread
879
- { name: "wvFsRead", symbol: "wv_fs_read", params: ["i32", "string"], returns: "i32" },
880
- { name: "wvFsWrite", symbol: "wv_fs_write", params: ["i32", "string", "string"], returns: "i32" },
881
- // Job accessors, shared by file I/O and dialogs: both are work whose
882
- // answer cannot be produced during the FFI call that starts it.
883
- { name: "wvJobStatus", symbol: "wv_job_status", params: ["i32", "i32"], returns: "i32" },
884
- { name: "wvJobSize", symbol: "wv_job_size", params: ["i32", "i32"], returns: "f64" },
885
- {
886
- // One slice per call, so a large payload decodes across several UI turns
887
- // instead of stalling on all of it at once. Returns the bytes covered.
888
- name: "wvJobTakeAt", symbol: "wv_job_take_at",
889
- params: [
890
- "i32", "i32", "f64", "f64",
891
- { callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
892
- { context: "sink" },
893
- ],
894
- returns: "f64",
895
- },
896
- { name: "wvJobFree", symbol: "wv_job_free", params: ["i32", "i32"], returns: "i32" },
897
- // Native dialogs: the modal runs on a later UI-thread turn, so asking for
898
- // one never blocks the invoke that asked. Options ride as plain params
899
- // (kind, flags, title, defaultPath, defaultName, filters).
900
- {
901
- name: "wvDialog", symbol: "wv_dialog",
902
- params: ["i32", "i32", "i32", "string", "string", "string", "string"],
903
- returns: "i32",
904
- },
905
- { name: "wvSetFullscreen", symbol: "wv_set_fullscreen", params: ["i32", "i32"], returns: "i32" },
906
- ];
907
-
908
- if (process.platform === "win32") {
909
- // MinGW ignores MSVC's #pragma comment(lib, ...), so the Win32 imports the
910
- // WebView2 backend needs are named explicitly. scriptc's own win32 lane
911
- // already adds advapi32/iphlpapi/ws2_32, so those are omitted here.
912
- // `c++` pulls libc++ for the shim's std::string/exceptions.
913
- //
914
- // `pthread` (mingw's libwinpthread) is here to work around an upstream
915
- // scriptc bug: its runtime calls clock_gettime/nanosleep, which mingw
916
- // declares in <time.h> but implements in winpthreads, and scriptc's win32
917
- // link never adds it. Without this the link dies with
918
- // "undefined symbol: clock_gettime" — reproducible with a plain
919
- // `scriptc build hello.ts` on Windows, no FFI involved.
920
- return {
921
- ffi_format: 4,
922
- functions,
923
- libraries: [shimLib],
924
- system_libraries: [
925
- "c++", "pthread",
926
- "ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
927
- // GetOpenFileNameW / GetSaveFileNameW for the native file dialogs.
928
- "comdlg32",
929
- ],
930
- };
931
- }
932
-
933
- if (process.platform === "darwin") {
934
- // scriptc has no -framework support, but `libraries` entries are passed to
935
- // the link as plain input files and ld64 accepts .tbd stubs.
936
- const sdk = capture(["xcrun", "--sdk", "macosx", "--show-sdk-path"]);
937
- return {
938
- ffi_format: 4,
939
- functions,
940
- libraries: [
941
- shimLib,
942
- join(sdk, "System/Library/Frameworks/WebKit.framework/WebKit.tbd"),
943
- join(sdk, "System/Library/Frameworks/Cocoa.framework/Cocoa.tbd"),
944
- ],
945
- system_libraries: ["c++"],
946
- };
947
- }
948
- return {
949
- ffi_format: 4,
950
- functions,
951
- libraries: [shimLib],
952
- system_libraries: [
953
- "stdc++", "webkit2gtk-4.1", "javascriptcoregtk-4.1", "gtk-3", "gdk-3",
954
- "soup-3.0", "gio-2.0", "gobject-2.0", "glib-2.0", "gmodule-2.0",
955
- "pango-1.0", "pangocairo-1.0", "harfbuzz", "atk-1.0", "cairo",
956
- "cairo-gobject", "gdk_pixbuf-2.0", "z", "pthread",
957
- ],
958
- };
959
- }
960
740
 
961
741
  // ---- Windows subsystem ------------------------------------------------------
962
742
 
@@ -972,34 +752,16 @@ function ffiManifest(shimLib) {
972
752
  // the loader whether to allocate a console. Every offset is checked before
973
753
  // anything is written, and a file that does not look like a console-subsystem
974
754
  // PE is left alone.
975
- const IMAGE_SUBSYSTEM_WINDOWS_GUI = 2;
976
- const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
977
755
 
978
756
  function makeGuiSubsystem(exePath) {
979
- const buf = readFileSync(exePath);
980
- if (buf.length < 0x40 || buf.readUInt16LE(0) !== 0x5a4d) {
981
- 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;
982
763
  }
983
- const peOff = buf.readUInt32LE(0x3c);
984
- if (peOff + 24 > buf.length || buf.readUInt32LE(peOff) !== 0x00004550) {
985
- fail(`${exePath} has no PE signature at ${peOff}`);
986
- }
987
- // Optional header starts after the 4-byte signature and 20-byte COFF header;
988
- // Subsystem sits at +68 in both PE32 (0x10b) and PE32+ (0x20b).
989
- const optOff = peOff + 24;
990
- const magic = buf.readUInt16LE(optOff);
991
- if (magic !== 0x10b && magic !== 0x20b) {
992
- fail(`${exePath} has an unrecognised optional header magic 0x${magic.toString(16)}`);
993
- }
994
- const subOff = optOff + 68;
995
- if (subOff + 2 > buf.length) fail(`${exePath} is truncated before its Subsystem field`);
996
- const current = buf.readUInt16LE(subOff);
997
- if (current === IMAGE_SUBSYSTEM_WINDOWS_GUI) return;
998
- if (current !== IMAGE_SUBSYSTEM_WINDOWS_CUI) {
999
- fail(`${exePath} has an unexpected subsystem ${current}; refusing to rewrite it`);
1000
- }
1001
- buf.writeUInt16LE(IMAGE_SUBSYSTEM_WINDOWS_GUI, subOff);
1002
- writeFileSync(exePath, buf);
764
+ if (result.patched) writeFileSync(exePath, result.buf);
1003
765
  }
1004
766
 
1005
767
  // ---- build ----------------------------------------------------------------
@@ -1187,10 +949,7 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1187
949
  // as well.
1188
950
  writeFileSync(
1189
951
  join(buildDir, "main.ts"),
1190
- readFileSync(mainSrc, "utf8").replace(
1191
- /(\bfrom\s*)(['"])janela\/host\2/g,
1192
- "$1$2./janela$2",
1193
- ),
952
+ rewriteHostSpecifier(readFileSync(mainSrc, "utf8")),
1194
953
  );
1195
954
 
1196
955
  const html = frontendHtml(root, conf, devUrl);
@@ -1261,7 +1020,12 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1261
1020
  if (ios) return buildIos(root, conf, buildDir, outDir);
1262
1021
  if (android) return buildAndroid(root, conf, buildDir, outDir);
1263
1022
 
1264
- 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");
1265
1029
 
1266
1030
  console.log("janela: compiling TypeScript to a native binary");
1267
1031
  // An explicit --out is used verbatim, so the PE suffix is ours to add.
@@ -1374,20 +1138,9 @@ function copyTemplate(from, to, name) {
1374
1138
  // allowed because people type them and every downstream use accepts them —
1375
1139
  // Android application ids in particular *prefer* them, since a Java package
1376
1140
  // segment cannot contain a hyphen (see androidApplicationId).
1377
- const NAME_RE = /^[a-z][a-z0-9_-]*$/;
1378
1141
 
1379
1142
  // Best-effort repair of a rejected name, so the error can suggest something
1380
1143
  // that would have worked instead of only stating the rule.
1381
- function suggestName(raw) {
1382
- const s = String(raw)
1383
- .toLowerCase()
1384
- .replace(/[^a-z0-9_-]+/g, "-")
1385
- .replace(/^[^a-z]+/, "")
1386
- .replace(/-{2,}/g, "-")
1387
- .replace(/[-_]+$/, "");
1388
- return NAME_RE.test(s) ? s : "";
1389
- }
1390
-
1391
1144
  function init(name, template) {
1392
1145
  if (!name) {
1393
1146
  fail(
@@ -1484,11 +1237,11 @@ async function dev(root) {
1484
1237
  const port = await freePort();
1485
1238
  devUrl = `http://localhost:${port}/`;
1486
1239
  console.log(`janela: starting the vite dev server on ${devUrl}`);
1487
- 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"], {
1488
1242
  cwd: root,
1489
1243
  stdio: "inherit",
1490
- // Windows resolves .cmd shims through the shell.
1491
- shell: process.platform === "win32",
1244
+ shell: viteCmd.shell,
1492
1245
  });
1493
1246
  const stop = () => { if (vite && vite.exitCode === null) vite.kill(); };
1494
1247
  process.on("exit", stop);
package/bin/lib.mjs ADDED
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Pure helpers shared by the CLI and its tests.
3
+ *
4
+ * `bin/janela.mjs` is build orchestration: it shells out, writes files and
5
+ * exits. The decisions inside it — how a name is validated, how a config's
6
+ * defaults fall back, which symbols an FFI manifest must declare, which bytes
7
+ * of a PE may be rewritten — are pure, and a wrong answer from any of them
8
+ * fails quietly rather than loudly: a missing FFI declaration makes the
9
+ * runtime fail to link, and a mis-coerced Android id is rejected by aapt2
10
+ * only at package time.
11
+ *
12
+ * So they live here, where a test can call them directly. Nothing in this
13
+ * file touches the filesystem, the network or a subprocess.
14
+ */
15
+
16
+ import { join } from "node:path";
17
+
18
+ // ---- project names ---------------------------------------------------------
19
+
20
+ export const NAME_RE = /^[a-z][a-z0-9_-]*$/;
21
+
22
+ export const MIME = {
23
+ ".css": "text/css", ".js": "text/javascript", ".json": "application/json",
24
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
25
+ ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
26
+ ".svg": "image/svg+xml", ".ico": "image/x-icon",
27
+ ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf",
28
+ ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".webm": "video/webm",
29
+ };
30
+ export const STR = (name, symbol) => ({ name, symbol, params: ["i32", "string"], returns: "i32" });
31
+ export const IOS_MIN_VERSION = "15.0";
32
+ export const IOS_PREFIX = "jl_";
33
+ export const ANDROID_MIN_SDK = 26;
34
+ export const ANDROID_TARGET_SDK = 34;
35
+ export const ANDROID_ABI = "arm64-v8a";
36
+ export const IMAGE_SUBSYSTEM_WINDOWS_GUI = 2;
37
+ export const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
38
+
39
+ export function suggestName(raw) {
40
+ const s = String(raw)
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9_-]+/g, "-")
43
+ .replace(/^[^a-z]+/, "")
44
+ .replace(/-{2,}/g, "-")
45
+ .replace(/[-_]+$/, "");
46
+ return NAME_RE.test(s) ? s : "";
47
+ }
48
+
49
+ export function androidPackage(id) {
50
+ return id
51
+ .split(".")
52
+ .map((seg) => {
53
+ const cleaned = seg.replace(/[^A-Za-z0-9_]/g, "_");
54
+ return /^[A-Za-z_]/.test(cleaned) ? cleaned : `_${cleaned}`;
55
+ })
56
+ .join(".");
57
+ }
58
+
59
+ export function androidConf(conf) {
60
+ const a = conf.android ?? {};
61
+ return {
62
+ applicationId: androidPackage(a.applicationId ?? a.identifier ?? conf.identifier),
63
+ label: a.label ?? conf.window?.title ?? conf.name,
64
+ minSdk: String(a.minSdk ?? ANDROID_MIN_SDK),
65
+ device: a.device ?? null,
66
+ };
67
+ }
68
+
69
+ export function iosConf(conf) {
70
+ const ios = conf.ios ?? {};
71
+ return {
72
+ identifier: ios.identifier ?? conf.identifier,
73
+ displayName: ios.displayName ?? conf.window?.title ?? conf.name,
74
+ minimumVersion: String(ios.minimumVersion ?? IOS_MIN_VERSION),
75
+ device: ios.device ?? null,
76
+ };
77
+ }
78
+
79
+ export function libraryProfile() {
80
+ return {
81
+ profile_format: 1,
82
+ name: "janela",
83
+ entry: "./entry.ts",
84
+ emission: "llvm",
85
+ abi: {
86
+ prefix: IOS_PREFIX,
87
+ init_symbol: `${IOS_PREFIX}init`,
88
+ sink_register_symbol: `${IOS_PREFIX}set_panic_sink`,
89
+ collect_symbol: `${IOS_PREFIX}collect`,
90
+ result_reset_symbol: `${IOS_PREFIX}reset`,
91
+ callback_register_symbol: `${IOS_PREFIX}set_callback`,
92
+ },
93
+ exports: [
94
+ {
95
+ export: "handleInvoke",
96
+ symbol: `${IOS_PREFIX}handle_invoke`,
97
+ params: ["string", "string"],
98
+ returns: "string",
99
+ },
100
+ {
101
+ export: "indexHtml",
102
+ symbol: `${IOS_PREFIX}index_html`,
103
+ params: [],
104
+ returns: "string",
105
+ },
106
+ // The shell calls these back on the main queue when work it owns comes
107
+ // due. They are the mirror of the desktop shim's wv_on_timer: the
108
+ // library parks a continuation under an id and is re-entered with it.
109
+ {
110
+ export: "onTimer",
111
+ symbol: `${IOS_PREFIX}on_timer`,
112
+ params: ["f64"],
113
+ returns: "void",
114
+ },
115
+ {
116
+ export: "onFsDone",
117
+ symbol: `${IOS_PREFIX}on_fs_done`,
118
+ params: ["f64", "bool", "string"],
119
+ returns: "void",
120
+ },
121
+ // Deliberately its own export rather than reusing onFsDone, whose
122
+ // signature would fit: a dialog result arriving through the file-I/O
123
+ // path would read as a bug for as long as the code lived.
124
+ {
125
+ export: "onDialogDone",
126
+ symbol: `${IOS_PREFIX}on_dialog_done`,
127
+ params: ["f64", "bool", "string"],
128
+ returns: "void",
129
+ },
130
+ ],
131
+ // TS -> shell. A channel handler must never re-enter the library (see
132
+ // upstream #263: violations silently appear to work), so every one of
133
+ // these only records the request; the shell acts on its own queue and
134
+ // re-enters through an export above on a later turn.
135
+ callbacks: [
136
+ { name: "janelaEmit", params: ["string", "string"], returns: "void" },
137
+ { name: "hostSchedule", params: ["f64", "f64"], returns: "void" },
138
+ { name: "hostSettle", params: ["f64", "string"], returns: "void" },
139
+ { name: "hostReadFile", params: ["f64", "string"], returns: "void" },
140
+ { name: "hostWriteFile", params: ["f64", "string", "string"], returns: "void" },
141
+ { name: "hostOpenDialog", params: ["f64", "string"], returns: "void" },
142
+ ],
143
+ };
144
+ }
145
+
146
+ export function mimeFor(p) {
147
+ const dot = p.lastIndexOf(".");
148
+ return (dot < 0 ? null : MIME[p.slice(dot).toLowerCase()]) ?? "application/octet-stream";
149
+ }
150
+
151
+ /**
152
+ * The FFI manifest a desktop build hands to scriptc: every shim symbol the
153
+ * runtime calls, plus the per-platform link inputs.
154
+ *
155
+ * `platform` and `macSdkPath` are parameters rather than ambient lookups so
156
+ * that every branch is reachable from any host — a manifest that silently
157
+ * loses a declaration fails at link time, on one platform, which is the worst
158
+ * place to find out.
159
+ */
160
+ export function ffiManifest(shimLib, { platform = process.platform, macSdkPath = null } = {}) {
161
+ const functions = [
162
+ { name: "wvCreate", symbol: "wv_create", params: ["i32"], returns: "i32" },
163
+ STR("wvSetTitle", "wv_set_title"),
164
+ { name: "wvSetSize", symbol: "wv_set_size", params: ["i32", "i32", "i32", "i32"], returns: "i32" },
165
+ STR("wvSetHtml", "wv_set_html"),
166
+ STR("wvInit", "wv_init"),
167
+ STR("wvEval", "wv_eval"),
168
+ STR("wvBind", "wv_bind"),
169
+ STR("wvReply", "wv_reply"),
170
+ // Retained handlers (format 4): registered once, valid for the app's
171
+ // lifetime, so wv_run is a plain blocking call. The request rides in as a
172
+ // `string` param (format 3) rather than a byte-at-a-time drain.
173
+ {
174
+ name: "wvOnInvoke", symbol: "wv_on_invoke",
175
+ params: [
176
+ "i32",
177
+ { callback: { id: "inv", params: ["string", { context: "inv" }], returns: "i32", lifetime: "retained" } },
178
+ { context: "inv" },
179
+ ],
180
+ returns: "i32",
181
+ },
182
+ {
183
+ name: "wvOnTimer", symbol: "wv_on_timer",
184
+ params: [
185
+ "i32",
186
+ { callback: { id: "timer", params: ["i32", { context: "timer" }], returns: "void", lifetime: "retained" } },
187
+ { context: "timer" },
188
+ ],
189
+ returns: "i32",
190
+ },
191
+ { name: "wvRun", symbol: "wv_run", params: ["i32"], returns: "i32" },
192
+ { name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
193
+ // async: the held-reply table (deferred returns) plus shell-owned
194
+ // scheduling — TS parks a continuation id, the shell calls it back due.
195
+ { name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
196
+ { name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
197
+ { name: "wvSchedule", symbol: "wv_schedule", params: ["i32", "i32", "i32"], returns: "i32" },
198
+ // async file I/O: the blocking syscall runs on a shim worker thread
199
+ { name: "wvFsRead", symbol: "wv_fs_read", params: ["i32", "string"], returns: "i32" },
200
+ { name: "wvFsWrite", symbol: "wv_fs_write", params: ["i32", "string", "string"], returns: "i32" },
201
+ // Job accessors, shared by file I/O and dialogs: both are work whose
202
+ // answer cannot be produced during the FFI call that starts it.
203
+ { name: "wvJobStatus", symbol: "wv_job_status", params: ["i32", "i32"], returns: "i32" },
204
+ { name: "wvJobSize", symbol: "wv_job_size", params: ["i32", "i32"], returns: "f64" },
205
+ {
206
+ // One slice per call, so a large payload decodes across several UI turns
207
+ // instead of stalling on all of it at once. Returns the bytes covered.
208
+ name: "wvJobTakeAt", symbol: "wv_job_take_at",
209
+ params: [
210
+ "i32", "i32", "f64", "f64",
211
+ { callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
212
+ { context: "sink" },
213
+ ],
214
+ returns: "f64",
215
+ },
216
+ { name: "wvJobFree", symbol: "wv_job_free", params: ["i32", "i32"], returns: "i32" },
217
+ // Native dialogs: the modal runs on a later UI-thread turn, so asking for
218
+ // one never blocks the invoke that asked. Options ride as plain params
219
+ // (kind, flags, title, defaultPath, defaultName, filters).
220
+ {
221
+ name: "wvDialog", symbol: "wv_dialog",
222
+ params: ["i32", "i32", "i32", "string", "string", "string", "string"],
223
+ returns: "i32",
224
+ },
225
+ { name: "wvSetFullscreen", symbol: "wv_set_fullscreen", params: ["i32", "i32"], returns: "i32" },
226
+ ];
227
+
228
+ if (platform === "win32") {
229
+ // MinGW ignores MSVC's #pragma comment(lib, ...), so the Win32 imports the
230
+ // WebView2 backend needs are named explicitly. scriptc's own win32 lane
231
+ // already adds advapi32/iphlpapi/ws2_32, so those are omitted here.
232
+ // `c++` pulls libc++ for the shim's std::string/exceptions.
233
+ //
234
+ // `pthread` (mingw's libwinpthread) is here to work around an upstream
235
+ // scriptc bug: its runtime calls clock_gettime/nanosleep, which mingw
236
+ // declares in <time.h> but implements in winpthreads, and scriptc's win32
237
+ // link never adds it. Without this the link dies with
238
+ // "undefined symbol: clock_gettime" — reproducible with a plain
239
+ // `scriptc build hello.ts` on Windows, no FFI involved.
240
+ return {
241
+ ffi_format: 4,
242
+ functions,
243
+ libraries: [shimLib],
244
+ system_libraries: [
245
+ "c++", "pthread",
246
+ "ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
247
+ // GetOpenFileNameW / GetSaveFileNameW for the native file dialogs.
248
+ "comdlg32",
249
+ ],
250
+ };
251
+ }
252
+
253
+ if (platform === "darwin") {
254
+ // scriptc has no -framework support, but `libraries` entries are passed to
255
+ // the link as plain input files and ld64 accepts .tbd stubs.
256
+ if (!macSdkPath) throw new Error("ffiManifest: macSdkPath is required on darwin");
257
+ const sdk = macSdkPath;
258
+ return {
259
+ ffi_format: 4,
260
+ functions,
261
+ libraries: [
262
+ shimLib,
263
+ join(sdk, "System/Library/Frameworks/WebKit.framework/WebKit.tbd"),
264
+ join(sdk, "System/Library/Frameworks/Cocoa.framework/Cocoa.tbd"),
265
+ ],
266
+ system_libraries: ["c++"],
267
+ };
268
+ }
269
+ return {
270
+ ffi_format: 4,
271
+ functions,
272
+ libraries: [shimLib],
273
+ system_libraries: [
274
+ "stdc++", "webkit2gtk-4.1", "javascriptcoregtk-4.1", "gtk-3", "gdk-3",
275
+ "soup-3.0", "gio-2.0", "gobject-2.0", "glib-2.0", "gmodule-2.0",
276
+ "pango-1.0", "pangocairo-1.0", "harfbuzz", "atk-1.0", "cairo",
277
+ "cairo-gobject", "gdk_pixbuf-2.0", "z", "pthread",
278
+ ],
279
+ };
280
+ }
281
+ // ---- host specifier rewrite -------------------------------------------------
282
+
283
+ /**
284
+ * Rewrite a project's `from "janela/host"` to the runtime copy the CLI places
285
+ * beside it in `.janela/build/`.
286
+ *
287
+ * A project imports the package specifier so that it resolves in an editor
288
+ * against the installed package; the build compiles against the local copy
289
+ * instead, which keeps it static (no node_modules resolution) and keeps
290
+ * working once "janela/host" exports values and not only types.
291
+ *
292
+ * Deliberately narrow: only a specifier in `from` position, only that exact
293
+ * module, either quote style. A mention inside a comment or a longer
294
+ * specifier such as "janela/host-extras" is left alone.
295
+ */
296
+ export function rewriteHostSpecifier(src) {
297
+ return src.replace(/(\bfrom\s*)(['"])janela\/host\2/g, "$1$2./janela$2");
298
+ }
299
+
300
+ // ---- Windows subsystem ------------------------------------------------------
301
+
302
+ /** A PE the patcher refused to touch, with `code` naming the reason. */
303
+ export class PeError extends Error {
304
+ constructor(code, message) {
305
+ super(message);
306
+ this.code = code;
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Flip a console-subsystem PE to the GUI subsystem, in place, on a copy of
312
+ * the caller's buffer.
313
+ *
314
+ * Returns `{ patched: false }` when the image is already GUI, or
315
+ * `{ patched: true, buf }` with the rewritten bytes. Anything that does not
316
+ * look like the console-subsystem PE we just linked throws a `PeError`
317
+ * instead of being modified — the whole point is to touch exactly one field
318
+ * of exactly one shape of file.
319
+ */
320
+ export function patchPeSubsystem(input) {
321
+ const buf = Buffer.from(input);
322
+ if (buf.length < 0x40 || buf.readUInt16LE(0) !== 0x5a4d) {
323
+ throw new PeError("no-mz", "not a PE image (no MZ header)");
324
+ }
325
+ const peOff = buf.readUInt32LE(0x3c);
326
+ if (peOff + 24 > buf.length || buf.readUInt32LE(peOff) !== 0x00004550) {
327
+ throw new PeError("no-pe", `no PE signature at ${peOff}`);
328
+ }
329
+ // Optional header starts after the 4-byte signature and 20-byte COFF header;
330
+ // Subsystem sits at +68 in both PE32 (0x10b) and PE32+ (0x20b).
331
+ const optOff = peOff + 24;
332
+ const magic = buf.readUInt16LE(optOff);
333
+ if (magic !== 0x10b && magic !== 0x20b) {
334
+ throw new PeError("bad-magic", `unrecognised optional header magic 0x${magic.toString(16)}`);
335
+ }
336
+ const subOff = optOff + 68;
337
+ if (subOff + 2 > buf.length) {
338
+ throw new PeError("truncated", "truncated before its Subsystem field");
339
+ }
340
+ const current = buf.readUInt16LE(subOff);
341
+ if (current === IMAGE_SUBSYSTEM_WINDOWS_GUI) return { patched: false };
342
+ if (current !== IMAGE_SUBSYSTEM_WINDOWS_CUI) {
343
+ throw new PeError("unexpected-subsystem", `unexpected subsystem ${current}; refusing to rewrite it`);
344
+ }
345
+ buf.writeUInt16LE(IMAGE_SUBSYSTEM_WINDOWS_GUI, subOff);
346
+ return { patched: true, buf };
347
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Desktop, iOS and Android apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,5 +56,13 @@
56
56
  "templates/",
57
57
  "vendor-webview/core/include/",
58
58
  "vendor-webview/LICENSE"
59
- ]
59
+ ],
60
+ "scripts": {
61
+ "test": "npm run test:unit && npm run test:types",
62
+ "test:unit": "node --test test/unit/*.test.mjs",
63
+ "test:types": "node test/types/run.mjs"
64
+ },
65
+ "devDependencies": {
66
+ "typescript": "^5.9.3"
67
+ }
60
68
  }
@@ -328,6 +328,15 @@ void finish_fs(double id, bool ok, std::string payload) {
328
328
  void host_read_file(void *, double id, const char *path, size_t path_len) {
329
329
  std::string p = resolve_path(std::string(path, path_len));
330
330
  std::thread([id, p] {
331
+ // As on iOS: a directory opens as an ifstream and reads as empty, which
332
+ // would report success with no content. Desktop answers EISDIR; keep the
333
+ // message identical across platforms.
334
+ struct stat st;
335
+ if (::stat(p.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) {
336
+ finish_fs(id, false,
337
+ "EISDIR: illegal operation on a directory, read '" + p + "'");
338
+ return;
339
+ }
331
340
  std::ifstream in(p, std::ios::binary);
332
341
  if (!in) {
333
342
  finish_fs(id, false,
package/shim/ios/app.cc CHANGED
@@ -277,6 +277,16 @@ void finish_fs(double id, bool ok, std::string payload) {
277
277
  void host_read_file(void *, double id, const char *path, size_t path_len) {
278
278
  std::string p = resolve_path(std::string(path, path_len));
279
279
  dispatch_async(fs_queue(), ^{
280
+ // A directory opens cleanly as an ifstream on Apple platforms and then
281
+ // reads as empty, so without this check readFileAsync would report
282
+ // success with no content. Desktop already answers EISDIR here; the
283
+ // message is kept identical so app code can treat the platforms alike.
284
+ struct stat st;
285
+ if (::stat(p.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) {
286
+ finish_fs(id, false,
287
+ "EISDIR: illegal operation on a directory, read '" + p + "'");
288
+ return;
289
+ }
280
290
  std::ifstream in(p, std::ios::binary);
281
291
  if (!in) {
282
292
  finish_fs(id, false, "ENOENT: no such file or directory, open '" + p + "'");