janela 0.13.0 → 0.14.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.
package/README.md CHANGED
@@ -5,8 +5,11 @@
5
5
  Desktop and mobile apps in pure TypeScript, compiled to native. No Rust, no
6
6
  Node, no Electron. The backend is TypeScript compiled to a native binary by
7
7
  [scriptc](https://scriptc.dev); the window is the OS webview via
8
- [webview/webview](https://github.com/webview/webview). Binaries come out
9
- around 400–500 KB, with no bundled browser and no bundled runtime.
8
+ [webview/webview](https://github.com/webview/webview). A desktop binary comes
9
+ out around 200–400 KB 191 KB for the smallest template — with no bundled
10
+ browser and no bundled runtime; iOS and Android bundles land around
11
+ 384–578 KB. Per-template figures are in
12
+ [docs/frontend.md](../../docs/frontend.md).
10
13
 
11
14
  Five targets, one runtime — the same `main.ts`, the same typed contract and the
12
15
  same frontend build for each:
@@ -542,7 +545,7 @@ app.command("add", (args) => {
542
545
  Mechanically: drop the `JSON.parse(argsJson)` (cast `args` instead), drop
543
546
  every `JSON.stringify` around a result, `resolve`/`reject`/`emit` payload, and
544
547
  return nothing at all where you used to return `"null"`. Requires Node 24 to
545
- build (scriptc 0.0.35's floor).
548
+ build (scriptc 0.0.36's floor).
546
549
 
547
550
  ## What the CLI hides
548
551
 
@@ -559,10 +562,6 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
559
562
  the boundary, so anything that survives `JSON.stringify`/`JSON.parse` round
560
563
  trips (including full Unicode). `args` is typed `unknown` — cast it to the
561
564
  shape you expect.
562
- - Never use a bare FFI call as a complete variable initializer or assignment
563
- RHS — it is silently miscompiled. Wrap it in any expression (`+ 0`). Plain
564
- TypeScript is unaffected; only the runtime does FFI, so app code rarely
565
- meets this.
566
565
  - One window per app for now. Host code is single-threaded: a synchronous
567
566
  command blocks the UI while it runs — use `commandAsync` + `defer`/`sleep`
568
567
  (see "Async commands") for anything slow.
@@ -583,8 +582,10 @@ It is **simulator-only** so far — device builds and code signing are not
583
582
  wired up yet. Commands, the typed
584
583
  contract, events, Vite frontends, async commands (`commandAsync`, `defer`,
585
584
  `sleep`) and file I/O all work the same as on desktop — the shell owns the
586
- clock and the file queue on both. File dialogs are not on iOS yet and report
587
- clearly when called; window control is a no-op there by nature. See
585
+ clock and the file queue on both. `openFileDialog` works as of 0.13.0 the
586
+ picked file is copied into the app container and that path returned, so
587
+ `readFileAsync` opens it exactly as on desktop. `saveFileDialog` reports
588
+ clearly when called, and window control is a no-op there by nature. See
588
589
  [docs/ios.md](../../docs/ios.md).
589
590
 
590
591
  ## Android
@@ -615,11 +616,18 @@ The design notes and scriptc findings behind it are in
615
616
  [docs/findings.md](../../docs/findings.md), with per-platform notes in
616
617
  [docs/ios.md](../../docs/ios.md) and [docs/android.md](../../docs/android.md).
617
618
 
618
- Not yet: native dialogs and window control on mobile; device builds and code
619
- signing; icons, installers and notarization; async commands that run in
620
- parallel (host code is single-threaded, so `commandAsync` interleaves and a
621
- CPU-bound handler still needs slicing); an async HTTP client; tray icons and
622
- menus; multi-window; directory picking on Windows; and `app.center()`.
619
+ Not yet: `saveFileDialog` and window control on mobile (both deliberate
620
+ mobile "save" means exporting a file that already exists, which the desktop
621
+ signature cannot express, and window geometry is meaningless on a phone);
622
+ iOS device builds and code signing; installers and notarization; async
623
+ commands that run in parallel (host code is single-threaded, so
624
+ `commandAsync` interleaves and a CPU-bound handler still needs slicing); an
625
+ async HTTP client; tray icons and menus; multi-window; directory picking on
626
+ Windows and iOS; and `app.center()`.
627
+
628
+ `openFileDialog` **does** work on iOS and Android as of 0.13.0 — a picked
629
+ file is copied into the app container and that path returned, so
630
+ `readFileAsync` opens it exactly as on desktop.
623
631
 
624
632
  ## Releasing
625
633
 
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);
@@ -1235,7 +994,7 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1235
994
  `}\n`
1236
995
  : `const app = createApp<CmdsOf<typeof setup>, EvtsOf<typeof setup>>(WINDOW);\n` +
1237
996
  `setup(app);\n` +
1238
- `const rc = app.run(INDEX_HTML) + 0;\n` +
997
+ `const rc = app.run(INDEX_HTML);\n` +
1239
998
  `console.log("[janela] run returned", rc);\n`;
1240
999
 
1241
1000
  writeFileSync(
@@ -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.14.0",
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": {
@@ -21,7 +21,7 @@
21
21
  "./package.json": "./package.json"
22
22
  },
23
23
  "dependencies": {
24
- "scriptc": "0.0.35"
24
+ "scriptc": "0.0.36"
25
25
  },
26
26
  "license": "MIT",
27
27
  "author": "Mateus Lage",
@@ -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
  }
package/runtime/janela.ts CHANGED
@@ -4,11 +4,6 @@
4
4
  // (name, argsJson) envelope, dispatched to handlers registered on the app
5
5
  // object. Handlers see decoded values — the runtime owns JSON at the boundary.
6
6
  // Backend→frontend events ride wv_eval into the injected bootstrap.
7
- //
8
- // NOTE ON STYLE: every FFI call whose result initializes a variable is written
9
- // `f(...) + 0`. scriptc miscompiles a bare FFI call used as a complete
10
- // initializer/assignment RHS — still true in 0.0.35, and reported upstream as
11
- // vercel-labs/scriptc#21. Any enclosing expression is the workaround.
12
7
 
13
8
  declare function wvCreate(debug: number): number;
14
9
  declare function wvSetTitle(h: number, title: string): number;
@@ -202,7 +197,7 @@ export class JanelaAppImpl<
202
197
  drainSize: number[] = [];
203
198
 
204
199
  constructor(cfg: WindowConfig) {
205
- const h = wvCreate(0) + 0;
200
+ const h = wvCreate(0);
206
201
  this.handle = h;
207
202
  wvSetTitle(h, cfg.title);
208
203
  wvSetSize(h, cfg.width, cfg.height, 0);
@@ -221,9 +216,7 @@ export class JanelaAppImpl<
221
216
  this.contIds.push(id);
222
217
  this.contFns.push(fn);
223
218
  const delay = ms > 0 ? ms : 0;
224
- // `+ 0` per the note at the top of this file: a bare FFI call is not safe
225
- // in every position, and this one is silently dropped without it.
226
- const rc = wvSchedule(this.handle, id, delay) + 0;
219
+ const rc = wvSchedule(this.handle, id, delay);
227
220
  if (rc < 0) console.log("[janela] could not schedule continuation", id);
228
221
  }
229
222
 
@@ -254,14 +247,14 @@ export class JanelaAppImpl<
254
247
  */
255
248
  drainSome(): void {
256
249
  if (this.drainIds.length === 0) return;
257
- const started = Date.now() + 0;
250
+ const started = Date.now();
258
251
 
259
252
  while (this.drainIds.length > 0) {
260
253
  let chunk = "";
261
254
  const taken =
262
255
  wvJobTakeAt(this.handle, this.drainIds[0], this.drainOff[0], DRAIN_SLICE, (text) => {
263
256
  chunk = text;
264
- }) + 0;
257
+ });
265
258
 
266
259
  // A negative count means the job vanished; treat the payload as final
267
260
  // rather than spinning on it forever.
@@ -334,7 +327,7 @@ export class JanelaAppImpl<
334
327
  const doneCbs: FsCallback[] = [];
335
328
  const doneOk: boolean[] = [];
336
329
  for (let i = 0; i < this.jobIds.length; i++) {
337
- const st = wvJobStatus(this.handle, this.jobIds[i]) + 0;
330
+ const st = wvJobStatus(this.handle, this.jobIds[i]);
338
331
  if (st === JOB_PENDING) {
339
332
  keptIds.push(this.jobIds[i]);
340
333
  keptCbs.push(this.jobCbs[i]);
@@ -355,7 +348,7 @@ export class JanelaAppImpl<
355
348
  this.drainOk.push(doneOk[i]);
356
349
  this.drainParts.push([]);
357
350
  this.drainOff.push(0);
358
- this.drainSize.push(wvJobSize(this.handle, doneIds[i]) + 0);
351
+ this.drainSize.push(wvJobSize(this.handle, doneIds[i]));
359
352
  }
360
353
  }
361
354
 
@@ -381,7 +374,7 @@ export class JanelaAppImpl<
381
374
  defaultPath === undefined ? "" : defaultPath,
382
375
  defaultName === undefined ? "" : defaultName,
383
376
  encodeFilters(filters),
384
- ) + 0;
377
+ );
385
378
  if (id < 0) {
386
379
  this.defer(() => cb(null, "EAGAIN: could not open a dialog"));
387
380
  return;
@@ -452,7 +445,7 @@ export class JanelaAppImpl<
452
445
  * worker thread; the callback lands on the UI thread on a later turn.
453
446
  */
454
447
  readFileAsync(path: string, cb: FsCallback): void {
455
- const id = wvFsRead(this.handle, path) + 0;
448
+ const id = wvFsRead(this.handle, path);
456
449
  if (id < 0) {
457
450
  this.defer(() => cb("EAGAIN: could not start a read of '" + path + "'", ""));
458
451
  return;
@@ -463,7 +456,7 @@ export class JanelaAppImpl<
463
456
 
464
457
  /** Write a file without blocking the window; cb(null) on success. */
465
458
  writeFileAsync(path: string, data: string, cb: (err: string | null) => void): void {
466
- const id = wvFsWrite(this.handle, path, data) + 0;
459
+ const id = wvFsWrite(this.handle, path, data);
467
460
  if (id < 0) {
468
461
  this.defer(() => cb("EAGAIN: could not start a write of '" + path + "'"));
469
462
  return;
@@ -555,7 +548,7 @@ export class JanelaAppImpl<
555
548
  // Park the page's promise: the shim holds this call's id and
556
549
  // answers it when resolve/reject reaches wvResolve, whenever
557
550
  // that is. Meanwhile the loop is free to serve other calls.
558
- const id = wvDefer(h) + 0;
551
+ const id = wvDefer(h);
559
552
  if (id < 0) {
560
553
  wvReply(h, encode("cannot defer command: " + cmd));
561
554
  return 1;
@@ -579,7 +572,7 @@ export class JanelaAppImpl<
579
572
 
580
573
  wvBind(h, "__invoke");
581
574
  wvSetHtml(h, html);
582
- const rc = wvRun(h) + 0;
575
+ const rc = wvRun(h);
583
576
  return rc;
584
577
  }
585
578
  }
@@ -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 + "'");
package/templates/main.ts CHANGED
@@ -3,10 +3,6 @@
3
3
  // Register commands here; the page calls them with `await janela.invoke(name, args)`.
4
4
  // Handlers take the arguments as a value and return a value — the runtime owns
5
5
  // JSON at the boundary, so there is no parsing or stringifying to do here.
6
- //
7
- // Gotcha inherited from scriptc: never use a bare FFI-backed call as a
8
- // complete variable initializer — wrap it in any expression (`+ 0`). Plain
9
- // TypeScript like everything in this file is unaffected.
10
6
 
11
7
  import type { JanelaApp } from "janela/host";
12
8
 
@@ -6,11 +6,8 @@
6
6
  // names, argument shapes, results and event payloads — with no code
7
7
  // generation and nothing to keep in sync.
8
8
  //
9
- // Two gotchas inherited from scriptc:
10
- // - never use a bare FFI-backed call as a complete variable initializer;
11
- // wrap it in any expression (`+ 0`);
12
- // - a command that returns nothing is declared `() => void` and its handler
13
- // returns `null`; every command answers the page's promise with a value.
9
+ // A command that returns nothing is declared `() => void` and its handler
10
+ // returns `null`; every command answers the page's promise with a value.
14
11
 
15
12
  import type { JanelaApp } from "janela/host";
16
13
 
@@ -6,11 +6,8 @@
6
6
  // names, argument shapes, results and event payloads — with no code
7
7
  // generation and nothing to keep in sync.
8
8
  //
9
- // Two gotchas inherited from scriptc:
10
- // - never use a bare FFI-backed call as a complete variable initializer;
11
- // wrap it in any expression (`+ 0`);
12
- // - a command that returns nothing is declared `() => void` and its handler
13
- // returns `null`; every command answers the page's promise with a value.
9
+ // A command that returns nothing is declared `() => void` and its handler
10
+ // returns `null`; every command answers the page's promise with a value.
14
11
 
15
12
  import type { JanelaApp } from "janela/host";
16
13
 
@@ -6,11 +6,8 @@
6
6
  // names, argument shapes, results and event payloads — with no code
7
7
  // generation and nothing to keep in sync.
8
8
  //
9
- // Two gotchas inherited from scriptc:
10
- // - never use a bare FFI-backed call as a complete variable initializer;
11
- // wrap it in any expression (`+ 0`);
12
- // - a command that returns nothing is declared `() => void` and its handler
13
- // returns `null`; every command answers the page's promise with a value.
9
+ // A command that returns nothing is declared `() => void` and its handler
10
+ // returns `null`; every command answers the page's promise with a value.
14
11
 
15
12
  import type { JanelaApp } from "janela/host";
16
13
 
@@ -6,11 +6,8 @@
6
6
  // names, argument shapes, results and event payloads — with no code
7
7
  // generation and nothing to keep in sync.
8
8
  //
9
- // Two gotchas inherited from scriptc:
10
- // - never use a bare FFI-backed call as a complete variable initializer;
11
- // wrap it in any expression (`+ 0`);
12
- // - a command that returns nothing is declared `() => void` and its handler
13
- // returns `null`; every command answers the page's promise with a value.
9
+ // A command that returns nothing is declared `() => void` and its handler
10
+ // returns `null`; every command answers the page's promise with a value.
14
11
 
15
12
  import type { JanelaApp } from "janela/host";
16
13