janela 0.9.0 → 0.10.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
@@ -542,10 +542,28 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
542
542
  - `console.log` from commands goes to stdout — visible under `janela dev`,
543
543
  not when launched from Finder.
544
544
 
545
+ ## iOS
546
+
547
+ An iOS build runs the same app from the same source — same `main.ts`, same
548
+ contract, same frontend:
549
+
550
+ ```bash
551
+ janela build --target ios # -> .janela/out-ios/<name>.app (simulator)
552
+ janela dev --target ios # build, boot a simulator, install, launch
553
+ ```
554
+
555
+ It is **not part of a release yet** and is simulator-only. Commands, the typed
556
+ contract, events, Vite frontends, async commands (`commandAsync`, `defer`,
557
+ `sleep`) and file I/O all work the same as on desktop — the shell owns the
558
+ clock and the file queue on both. File dialogs are not on iOS yet and report
559
+ clearly when called; window control is a no-op there by nature. See
560
+ [docs/ios.md](../../docs/ios.md).
561
+
545
562
  ## Status
546
563
 
547
564
  Early proof of concept, on macOS (arm64), Linux (WebKitGTK) and Windows
548
- (WebView2). The design notes and scriptc findings behind it are in
565
+ (WebView2), with iOS on a branch (above). The design notes and scriptc
566
+ findings behind it are in
549
567
  [docs/findings.md](../../docs/findings.md). Not yet: async commands that run in
550
568
  parallel (host code is single-threaded; `commandAsync` interleaves instead),
551
569
  tray icons and menus, multi-window, directory picking on Windows,
package/bin/janela.mjs CHANGED
@@ -263,8 +263,213 @@ function webview2Include(cacheDir) {
263
263
  return incDir;
264
264
  }
265
265
 
266
+ // ---- iOS ------------------------------------------------------------------
267
+ //
268
+ // The second build lane. Desktop compiles TypeScript to an EXECUTABLE that
269
+ // drives a C library over FFI; iOS compiles it to a LIBRARY that a UIKit shell
270
+ // drives. scriptc refuses executables for iOS targets, so the inversion is not
271
+ // a preference — and library mode links no event loop (SC4005), which is why
272
+ // the async surface is desktop-only there.
273
+ //
274
+ // Everything below is simulator-only: a device build additionally needs a
275
+ // signing identity and a provisioning profile, which is its own project.
276
+
277
+ const IOS_MIN_VERSION = "15.0";
278
+ const IOS_PREFIX = "jl_";
279
+
280
+ function iosDeviceOrFail(name) {
281
+ const json = capture(["xcrun", "simctl", "list", "devices", "available", "--json"]);
282
+ const devices = JSON.parse(json).devices ?? {};
283
+ const runtimes = Object.keys(devices).filter((k) => /iOS/i.test(k));
284
+ if (runtimes.length === 0) {
285
+ fail(
286
+ "no iOS simulator runtime is installed — open Xcode > Settings > Components " +
287
+ "and get an iOS simulator, or run `xcodebuild -downloadPlatform iOS` in a terminal " +
288
+ "(it needs admin rights, so it cannot run unattended)",
289
+ );
290
+ }
291
+ const all = runtimes.flatMap((k) => devices[k]);
292
+ const pick = name
293
+ ? all.find((d) => d.name === name)
294
+ : all.find((d) => /^iPhone/.test(d.name)) ?? all[0];
295
+ if (!pick) fail(`no simulator named '${name}' — see \`xcrun simctl list devices\``);
296
+ return pick;
297
+ }
298
+
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
+ // The library profile: one export carries every command, so a project's own
310
+ // commands need no ABI of their own. `janelaEmit` is the reverse channel the
311
+ // shell registers before init.
312
+ function iosProfile() {
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
+ function iosPlist(conf) {
370
+ const ios = iosConf(conf);
371
+ return `<?xml version="1.0" encoding="UTF-8"?>
372
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
373
+ <plist version="1.0">
374
+ <dict>
375
+ <key>CFBundleName</key><string>${conf.name}</string>
376
+ <key>CFBundleDisplayName</key><string>${ios.displayName}</string>
377
+ <key>CFBundleIdentifier</key><string>${ios.identifier}</string>
378
+ <key>CFBundleExecutable</key><string>${conf.name}</string>
379
+ <key>CFBundlePackageType</key><string>APPL</string>
380
+ <key>CFBundleVersion</key><string>${conf.version ?? "0.1.0"}</string>
381
+ <key>CFBundleShortVersionString</key><string>${conf.version ?? "0.1.0"}</string>
382
+ <key>LSRequiresIPhoneOS</key><true/>
383
+ <key>UILaunchScreen</key><dict/>
384
+ <key>MinimumOSVersion</key><string>${ios.minimumVersion}</string>
385
+ <key>CFBundleSupportedPlatforms</key><array><string>iPhoneSimulator</string></array>
386
+ </dict>
387
+ </plist>
388
+ `;
389
+ }
390
+
391
+ function buildIos(root, conf, buildDir, outDir) {
392
+ if (process.platform !== "darwin") {
393
+ fail("iOS builds need macOS with Xcode — `--target ios` is only available there");
394
+ }
395
+ if (!which("zig")) {
396
+ fail(
397
+ "iOS builds need zig on PATH: scriptc routes mobile targets through `zig cc`. " +
398
+ "Install it with `brew install zig`",
399
+ );
400
+ }
401
+ const ios = iosConf(conf);
402
+
403
+ writeFileSync(join(buildDir, "profile.json"), JSON.stringify(iosProfile(), null, 2) + "\n");
404
+
405
+ console.log("janela: compiling TypeScript to an iOS library");
406
+ run(["node", scriptcBin(), "build", "--lib", "--profile", "profile.json"], {
407
+ cwd: buildDir,
408
+ env: {
409
+ ...process.env,
410
+ SCRIPTC_CC: "zigcc",
411
+ SCRIPTC_TARGET: "aarch64-apple-ios-simulator",
412
+ },
413
+ });
414
+ const lib = join(buildDir, ".scriptc", "entry.lib.a");
415
+ if (!existsSync(lib)) fail(`scriptc produced no library at ${lib}`);
416
+
417
+ console.log("janela: compiling the UIKit shell");
418
+ const bundle = join(outDir, `${conf.name}.app`);
419
+ mkdirSync(bundle, { recursive: true });
420
+ const sdk = capture(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"]);
421
+ // The .mm extension picks Objective-C++; an explicit `-x objective-c++`
422
+ // would leak onto the archive that follows and be compiled as source.
423
+ run([
424
+ "xcrun", "clang++",
425
+ join(KIT, "shim", "ios", "app.mm"),
426
+ "-target", `arm64-apple-ios${ios.minimumVersion}-simulator`,
427
+ "-isysroot", sdk,
428
+ "-fobjc-arc", "-std=c++17", "-O2",
429
+ "-framework", "UIKit", "-framework", "WebKit", "-framework", "Foundation",
430
+ lib,
431
+ "-o", join(bundle, conf.name),
432
+ ]);
433
+ run(["strip", join(bundle, conf.name)]);
434
+ writeFileSync(join(bundle, "Info.plist"), iosPlist(conf));
435
+
436
+ console.log(
437
+ `janela: built ${relative(root, bundle)} ` +
438
+ `(${statSync(join(bundle, conf.name)).size} bytes, iOS Simulator)`,
439
+ );
440
+ return bundle;
441
+ }
442
+
443
+ async function devIos(root) {
444
+ const conf = loadConf(root);
445
+ const device = iosDeviceOrFail(iosConf(conf).device);
446
+ const bundle = build(root, { target: "ios" });
447
+
448
+ if (device.state !== "Booted") {
449
+ console.log(`janela: booting ${device.name}`);
450
+ spawnSync("xcrun", ["simctl", "boot", device.udid]);
451
+ spawnSync("xcrun", ["simctl", "bootstatus", device.udid, "-b"]);
452
+ }
453
+ spawnSync("open", ["-a", "Simulator"]);
454
+
455
+ console.log(`janela: installing on ${device.name}`);
456
+ run(["xcrun", "simctl", "install", device.udid, bundle]);
457
+ console.log("janela: launching (Ctrl-C to stop following the log)");
458
+ run([
459
+ "xcrun", "simctl", "launch", "--console-pty",
460
+ device.udid, iosConf(conf).identifier,
461
+ ]);
462
+ }
463
+
266
464
  // ---- shim ----------------------------------------------------------------
267
465
 
466
+ function which(bin) {
467
+ const r = spawnSync(process.platform === "win32" ? "where" : "which", [bin], {
468
+ encoding: "utf8",
469
+ });
470
+ return r.status === 0 ? r.stdout.trim().split("\n")[0] : null;
471
+ }
472
+
268
473
  function buildShim(cacheDir) {
269
474
  const src = join(KIT, "shim", "wvshim.cc");
270
475
  const win = process.platform === "win32";
@@ -470,17 +675,23 @@ function makeGuiSubsystem(exePath) {
470
675
 
471
676
  // `devUrl` points the window at a running vite server instead of inlining the
472
677
  // frontend; `gui` asks for a GUI-subsystem .exe on Windows (build, not dev).
473
- function build(root, { devUrl = null, gui = true } = {}) {
678
+ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
474
679
  const conf = loadConf(root);
475
- const buildDir = join(root, ".janela", "build");
680
+ const ios = target === "ios";
681
+ const buildDir = join(root, ".janela", ios ? "build-ios" : "build");
476
682
  const cacheDir = join(root, ".janela", "cache");
477
- const outDir = join(root, ".janela", "out");
683
+ const outDir = join(root, ".janela", ios ? "out-ios" : "out");
478
684
  for (const d of [buildDir, cacheDir, outDir]) mkdirSync(d, { recursive: true });
479
685
 
480
- const shimLib = buildShim(cacheDir);
686
+ // Desktop links a C shim over webview.h; iOS links no shim at all — the
687
+ // UIKit shell is the program, and it links this build's output instead.
688
+ const shimLib = ios ? null : buildShim(cacheDir);
481
689
 
482
690
  // Assemble the compile unit: runtime + user's commands + generated modules.
483
- cpSync(join(KIT, "runtime", "janela.ts"), join(buildDir, "janela.ts"));
691
+ // Which runtime lane lands here as "./janela" is the whole difference
692
+ // between the two targets — a project's main.ts is compiled unchanged
693
+ // against either.
694
+ cpSync(join(KIT, "runtime", ios ? "ios.ts" : "janela.ts"), join(buildDir, "janela.ts"));
484
695
  cpSync(join(KIT, "runtime", "types.ts"), join(buildDir, "types.ts"));
485
696
  const mainSrc = join(root, "src-host", "main.ts");
486
697
  if (!existsSync(mainSrc)) fail("missing src-host/main.ts");
@@ -512,6 +723,34 @@ function build(root, { devUrl = null, gui = true } = {}) {
512
723
  `width: ${Number(w.width ?? 800)}, height: ${Number(w.height ?? 600)} };\n`,
513
724
  );
514
725
 
726
+ // The iOS entry exports the library's two entry points instead of running a
727
+ // loop: UIKit owns the loop and calls in. Everything above this line — the
728
+ // contract, main.ts, the flattened frontend — is identical to desktop.
729
+ const entryTail = ios
730
+ ? `const app = createApp<CmdsOf<typeof setup>, EvtsOf<typeof setup>>(WINDOW);\n` +
731
+ `setup(app);\n` +
732
+ `app.setHtml(INDEX_HTML);\n\n` +
733
+ `/** One page invoke; the UIKit shell calls this through the library ABI. */\n` +
734
+ `export function handleInvoke(cmd: string, argsJson: string): string {\n` +
735
+ ` return app.dispatch(cmd, argsJson);\n` +
736
+ `}\n\n` +
737
+ `/** The document the shell loads into its WKWebView. */\n` +
738
+ `export function indexHtml(): string {\n` +
739
+ ` return app.indexHtml();\n` +
740
+ `}\n\n` +
741
+ `/** A continuation the shell parked has come due (main queue). */\n` +
742
+ `export function onTimer(id: number): void {\n` +
743
+ ` app.onTimer(id);\n` +
744
+ `}\n\n` +
745
+ `/** A file job the shell owns has finished (main queue). */\n` +
746
+ `export function onFsDone(id: number, ok: boolean, payload: string): void {\n` +
747
+ ` app.onFsDone(id, ok, payload);\n` +
748
+ `}\n`
749
+ : `const app = createApp<CmdsOf<typeof setup>, EvtsOf<typeof setup>>(WINDOW);\n` +
750
+ `setup(app);\n` +
751
+ `const rc = app.run(INDEX_HTML) + 0;\n` +
752
+ `console.log("[janela] run returned", rc);\n`;
753
+
515
754
  writeFileSync(
516
755
  join(buildDir, "entry.ts"),
517
756
  `// Generated by janela — do not edit.\n` +
@@ -529,12 +768,11 @@ function build(root, { devUrl = null, gui = true } = {}) {
529
768
  `// and what is wanted here is the normalised table anyway.\n` +
530
769
  `type CmdsOf<F> = F extends (app: JanelaAppImpl<infer C, infer _E>) => void ? C : CommandShapes;\n` +
531
770
  `type EvtsOf<F> = F extends (app: JanelaAppImpl<infer _C, infer E>) => void ? E : Record<string, unknown>;\n\n` +
532
- `const app = createApp<CmdsOf<typeof setup>, EvtsOf<typeof setup>>(WINDOW);\n` +
533
- `setup(app);\n` +
534
- `const rc = app.run(INDEX_HTML) + 0;\n` +
535
- `console.log("[janela] run returned", rc);\n`,
771
+ entryTail,
536
772
  );
537
773
 
774
+ if (ios) return buildIos(root, conf, buildDir, outDir);
775
+
538
776
  writeFileSync(join(buildDir, "janela.ffi.json"), JSON.stringify(ffiManifest(shimLib), null, 2) + "\n");
539
777
 
540
778
  console.log("janela: compiling TypeScript to a native binary");
@@ -730,20 +968,30 @@ function positionals() {
730
968
  return out;
731
969
  }
732
970
 
971
+ const TARGETS = ["desktop", "ios"];
972
+
973
+ function targetOrFail() {
974
+ const t = flag("target", "desktop");
975
+ if (!TARGETS.includes(t)) fail(`unknown target '${t}' (${TARGETS.join(", ")})`);
976
+ return t;
977
+ }
978
+
733
979
  switch (cmd) {
734
980
  case "init":
735
981
  init(positionals()[0], flag("template", "vanilla"));
736
982
  break;
737
983
  case "build":
738
- build(process.cwd());
984
+ build(process.cwd(), { target: targetOrFail() });
739
985
  break;
740
986
  case "dev":
741
- await dev(process.cwd());
987
+ if (targetOrFail() === "ios") await devIos(process.cwd());
988
+ else await dev(process.cwd());
742
989
  break;
743
990
  default:
744
991
  console.log(
745
992
  "usage: janela init <name> [--template vanilla|vue|react|svelte|solid]\n" +
746
- " janela build | janela dev",
993
+ " janela build [--target desktop|ios]\n" +
994
+ " janela dev [--target desktop|ios]",
747
995
  );
748
996
  process.exit(cmd ? 1 : 0);
749
997
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.9.0",
4
- "description": "Desktop apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
3
+ "version": "0.10.0",
4
+ "description": "Desktop and iOS apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "janela": "bin/janela.mjs",
@@ -40,7 +40,8 @@
40
40
  "native",
41
41
  "tauri",
42
42
  "electron-alternative",
43
- "scriptc"
43
+ "scriptc",
44
+ "ios"
44
45
  ],
45
46
  "engines": {
46
47
  "node": ">=24"
package/runtime/ios.ts ADDED
@@ -0,0 +1,483 @@
1
+ // janela's iOS runtime lane.
2
+ //
3
+ // Same public surface as runtime/janela.ts — `app.command`, `app.emit`, the
4
+ // typed contract — so a project's src-host/main.ts compiles unchanged on both.
5
+ // The CLI picks a lane by copying one of these two files in as `./janela`.
6
+ //
7
+ // What differs is who is in charge. On desktop, TypeScript owns `main` and
8
+ // calls a blocking wvRun(); scriptc's event loop is parked for the app's life,
9
+ // so this runtime carries a ticker, a timer queue and a deferred-job pool to
10
+ // get work done anyway. On iOS none of that can exist:
11
+ //
12
+ // - scriptc builds iOS as a LIBRARY (it refuses executables for the target),
13
+ // and library mode requires an async-free module graph — SC4005 rejects a
14
+ // build whose graph reaches setTimeout, promises or threads. There is no
15
+ // event loop linked into the artifact at all.
16
+ // - UIKit owns the run loop and calls us. Each handleInvoke() runs to
17
+ // completion and returns, so nothing needs pumping.
18
+ //
19
+ // The result is much smaller: a command registry and a dispatch function.
20
+ // Everything that needed the loop is not available on iOS *yet* — it reports
21
+ // when called rather than failing silently, and every such path goes through
22
+ // one guard so that restoring parity is a single edit. See the stubs below.
23
+
24
+ import type {
25
+ AsyncCommandHandler,
26
+ CommandHandler,
27
+ CommandShapes,
28
+ CommandSpecs,
29
+ DialogFilter,
30
+ FsCallback,
31
+ Norm,
32
+ OpenDialogOptions,
33
+ SaveDialogOptions,
34
+ WindowConfig,
35
+ } from "./types";
36
+
37
+ // The host-callback channel declared in the generated library profile. The
38
+ // shell registers it before jl_init(); calling a channel the host never
39
+ // registered is a defined trap (SC4025), not undefined behaviour.
40
+ declare function janelaEmit(event: string, payloadJson: string): void;
41
+ declare function hostSchedule(id: number, ms: number): void;
42
+ declare function hostSettle(pendingId: number, envelopeJson: string): void;
43
+ declare function hostReadFile(jobId: number, path: string): void;
44
+ declare function hostWriteFile(jobId: number, path: string, data: string): void;
45
+
46
+ // One job table serves reads and writes; these sentinels say which callback of
47
+ // the pair is the real one. Identity comparison is the whole trick, so they
48
+ // must be module-level singletons rather than fresh closures.
49
+ const nullRead: FsCallback = (_e: string | null, _t: string) => {};
50
+ const nullWrite: (err: string | null) => void = (_e: string | null) => {};
51
+
52
+ function encode(value: unknown): string {
53
+ if (value === undefined) return "null";
54
+ return JSON.stringify(value);
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // The one place iOS says "not yet"
59
+ // ---------------------------------------------------------------------------
60
+ //
61
+ // Everything below funnels through `pending()`. Nothing else in this file
62
+ // decides what is or is not available, so the day a capability lands on iOS
63
+ // its stub becomes a real implementation and this comment shrinks.
64
+ //
65
+ // The scheduling family — commandAsync, defer, sleep — and file I/O now work
66
+ // exactly as they do on desktop: TS never holds a timer or a file handle, it
67
+ // parks work with the shell under an id and is re-entered when the shell has
68
+ // an answer. A library build still links no event loop (SC4005); that is why
69
+ // the design routes through the shell rather than a limitation of iOS.
70
+ //
71
+ // What remains behind pending(): the file dialogs (iOS wants a document
72
+ // picker, which is its own delegate lifecycle) and window control, which is
73
+ // permanently meaningless on a phone rather than unfinished.
74
+ //
75
+ // FAILING LOUDLY WITHOUT KILLING THE APP: an uncaught throw in library mode
76
+ // reaches the panic sink and then ABORTS the process (SC4013). So a stub must
77
+ // never throw from setup() — registering an async command at startup would
78
+ // kill the app before its first frame. Stubs that hand back a callback report
79
+ // through it; fire-and-forget stubs log; and commandAsync registers a command
80
+ // that throws only when the page calls it, where dispatch()'s try/catch turns
81
+ // it into a rejected promise.
82
+
83
+ /** The single message every not-yet-on-iOS path reports. */
84
+ function pending(api: string, why: string): string {
85
+ return (
86
+ "janela: app." + api + " is not available on iOS yet — " + why + ". " +
87
+ "Parity is planned; see docs/ios.md."
88
+ );
89
+ }
90
+
91
+ /**
92
+ * The scheduling family's guard — commandAsync, defer and sleep.
93
+ *
94
+ * These three are absent for one shared reason, and will return for one
95
+ * shared reason. Replacing this function with a real implementation (timers
96
+ * scheduled by the shell, the library re-entered when they fire) is the whole
97
+ * of that change on this side.
98
+ */
99
+ /**
100
+ * A running janela app on iOS, typed by the contract it serves.
101
+ *
102
+ * Deliberately the same class name as the desktop lane: the generated entry
103
+ * and a project's main.ts are compiled against whichever file the CLI copied
104
+ * in, so both must present the same type.
105
+ */
106
+ export class JanelaAppImpl<
107
+ C extends CommandShapes = CommandShapes,
108
+ E = Record<string, unknown>,
109
+ > {
110
+ names: string[] = [];
111
+ handlers: CommandHandler[] = [];
112
+ html = "";
113
+
114
+ // ---- scheduling ----------------------------------------------------------
115
+ // Identical in shape to the desktop lane, and for a stronger reason: an iOS
116
+ // library links no event loop at all (SC4005), so TS could not hold a timer
117
+ // even if it wanted to. It parks a continuation under an id, asks the shell
118
+ // to schedule it, and the shell re-enters onTimer(id) on the main queue when
119
+ // it comes due. Nothing here polls.
120
+ contIds: number[] = [];
121
+ contFns: (() => void)[] = [];
122
+ nextCont = 1;
123
+
124
+ // An invoke whose answer is not ready when dispatch() returns. The shell
125
+ // holds the page's reply under this id and settles it when hostSettle()
126
+ // arrives. Mirrors the desktop shim's wv_defer/wv_resolve pair.
127
+ pendingIds: number[] = [];
128
+ nextPending = 1;
129
+ deferred = -1; // set by an async command during its own dispatch()
130
+
131
+ // In-flight file jobs: the shell does the blocking I/O on its own queue and
132
+ // re-enters onFsDone() with the result.
133
+ jobIds: number[] = [];
134
+ jobCbs: FsCallback[] = [];
135
+ jobWriteCbs: ((err: string | null) => void)[] = [];
136
+ nextJob = 1;
137
+
138
+ // Kept so the shared WindowConfig shape compiles; iOS has no window to size.
139
+ constructor(_cfg: WindowConfig) {}
140
+
141
+ /**
142
+ * Register a named command, callable from the page as janela.invoke(name, args).
143
+ *
144
+ * With a contract (`JanelaApp<App>`) the name must be one the contract
145
+ * declares, `args` is inferred from it, and the return value is checked
146
+ * against it. Without one, `args` is `unknown` and any name is accepted.
147
+ */
148
+ command<K extends keyof C & string>(
149
+ name: K,
150
+ handler: (args: C[K]["args"]) => C[K]["result"],
151
+ ): void {
152
+ this.names.push(name);
153
+ // The cast is on the VALUE, inside a contextually-typed closure: casting
154
+ // the function itself to another signature and calling through it fails
155
+ // at runtime.
156
+ this.handlers.push((args: unknown) => handler(args as C[K]["args"]));
157
+ }
158
+
159
+ /**
160
+ * Fire an event into the page; the payload is delivered as a value. Under a
161
+ * contract, the name must be declared and the payload must match its type.
162
+ *
163
+ * Reaches the page through the host-callback channel: the shell evaluates
164
+ * `window.__wvEmit(...)` on the main queue.
165
+ */
166
+ emit<K extends keyof E & string>(event: K, payload: E[K]): void {
167
+ janelaEmit(event, encode(payload));
168
+ }
169
+
170
+ /**
171
+ * Answer one page invoke. Called by the generated entry, which the shell
172
+ * calls through the library's C ABI.
173
+ *
174
+ * A command that throws is caught here and reported as a rejection rather
175
+ * than taking the app down with it — on iOS an uncaught throw would abort
176
+ * the process.
177
+ */
178
+ dispatch(cmd: string, argsJson: string): string {
179
+ try {
180
+ const args = JSON.parse(argsJson) as unknown;
181
+ for (let i = 0; i < this.names.length; i++) {
182
+ if (this.names[i] === cmd) {
183
+ this.deferred = -1;
184
+ const value = this.handlers[i](args);
185
+ // An async command parked its answer during the call above. Tell the
186
+ // shell to hold the page's reply under that id instead of settling
187
+ // now; hostSettle() answers it later.
188
+ if (this.deferred >= 0) {
189
+ const held = this.deferred;
190
+ this.deferred = -1;
191
+ return encode({ pending: held });
192
+ }
193
+ return encode({ ok: true, value });
194
+ }
195
+ }
196
+ return encode({ ok: false, error: "unknown command: " + cmd });
197
+ } catch (e) {
198
+ return encode({ ok: false, error: (e as Error).message });
199
+ }
200
+ }
201
+
202
+ /** The document the shell should load. Set by the generated entry. */
203
+ setHtml(html: string): void {
204
+ this.html = html;
205
+ }
206
+
207
+ indexHtml(): string {
208
+ return this.html;
209
+ }
210
+
211
+ // -------------------------------------------------------------------------
212
+ // Not yet on iOS
213
+ // -------------------------------------------------------------------------
214
+ // Present so that a project written for desktop still COMPILES for iOS —
215
+ // the typed contract and main.ts are shared source. Each reports clearly at
216
+ // the point of use instead of doing nothing quietly, and each routes through
217
+ // pending() above so there is one place to change.
218
+
219
+ /**
220
+ * An async command: answer later, without freezing the window.
221
+ *
222
+ * The handler runs during dispatch() but need not produce a value. It parks
223
+ * a pending id; dispatch() returns that id to the shell, which holds the
224
+ * page's promise until resolve/reject settles it. Same contract as desktop.
225
+ */
226
+ commandAsync<K extends keyof C & string>(
227
+ name: K,
228
+ handler: (
229
+ args: C[K]["args"],
230
+ resolve: (value: C[K]["result"]) => void,
231
+ reject: (reason: unknown) => void,
232
+ ) => void,
233
+ ): void {
234
+ this.names.push(name);
235
+ this.handlers.push((args: unknown) => {
236
+ const id = this.nextPending;
237
+ this.nextPending = id + 1;
238
+ this.pendingIds.push(id);
239
+ // dispatch() reads this immediately after the handler returns.
240
+ this.deferred = id;
241
+ handler(
242
+ args as C[K]["args"],
243
+ (value: C[K]["result"]) => {
244
+ this.settle(id, encode({ ok: true, value: value }));
245
+ },
246
+ (reason: unknown) => {
247
+ this.settle(id, encode({ ok: false, error: String(reason) }));
248
+ },
249
+ );
250
+ return null;
251
+ });
252
+ }
253
+
254
+ /** Settle a held reply once, ignoring a second resolve/reject. */
255
+ settle(id: number, envelope: string): void {
256
+ for (let i = 0; i < this.pendingIds.length; i++) {
257
+ if (this.pendingIds[i] === id) {
258
+ this.pendingIds.splice(i, 1);
259
+ hostSettle(id, envelope);
260
+ return;
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Park `fn` with the shell and ask to be called back in `ms`.
267
+ *
268
+ * The id is the whole protocol: TS keeps the closure, the shell keeps the
269
+ * clock. Identical to the desktop lane.
270
+ */
271
+ schedule(ms: number, fn: () => void): void {
272
+ const id = this.nextCont;
273
+ this.nextCont = id + 1;
274
+ this.contIds.push(id);
275
+ this.contFns.push(fn);
276
+ const delay = ms > 0 ? ms : 0;
277
+ hostSchedule(id, delay);
278
+ }
279
+
280
+ /** Run fn on the next turn of the host loop — no timer involved. */
281
+ defer(fn: () => void): void {
282
+ this.schedule(0, fn);
283
+ }
284
+
285
+ /** Run fn after roughly `ms`, without blocking the window meanwhile. */
286
+ sleep(ms: number, fn: () => void): void {
287
+ this.schedule(ms, fn);
288
+ }
289
+
290
+ /**
291
+ * Called by the shell on the main queue when a continuation comes due.
292
+ *
293
+ * This always lands at the top of a fresh turn with no TS frame beneath it,
294
+ * because the shell posts through its own queue rather than calling back
295
+ * from inside a channel handler (upstream #263: a breach silently appears
296
+ * to work, so it has to hold by construction).
297
+ */
298
+ onTimer(id: number): void {
299
+ for (let i = 0; i < this.contIds.length; i++) {
300
+ if (this.contIds[i] === id) {
301
+ const fn = this.contFns[i];
302
+ // Unregister BEFORE running: a continuation that schedules another one
303
+ // must not disturb the entry being removed.
304
+ this.contIds.splice(i, 1);
305
+ this.contFns.splice(i, 1);
306
+ fn();
307
+ return;
308
+ }
309
+ }
310
+ }
311
+
312
+ /** Read a file without blocking the UI; the shell does the I/O off-queue. */
313
+ readFileAsync(path: string, cb: FsCallback): void {
314
+ const id = this.nextJob;
315
+ this.nextJob = id + 1;
316
+ this.jobIds.push(id);
317
+ this.jobCbs.push(cb);
318
+ this.jobWriteCbs.push(nullWrite);
319
+ hostReadFile(id, path);
320
+ }
321
+
322
+ /** Write a file without blocking the UI; the shell does the I/O off-queue. */
323
+ writeFileAsync(path: string, data: string, cb: (err: string | null) => void): void {
324
+ const id = this.nextJob;
325
+ this.nextJob = id + 1;
326
+ this.jobIds.push(id);
327
+ this.jobCbs.push(nullRead);
328
+ this.jobWriteCbs.push(cb);
329
+ hostWriteFile(id, path, data);
330
+ }
331
+
332
+ /**
333
+ * Called by the shell on the main queue when a file job finishes. `payload`
334
+ * carries the contents on a read, or the error message when `ok` is false.
335
+ */
336
+ onFsDone(id: number, ok: boolean, payload: string): void {
337
+ for (let i = 0; i < this.jobIds.length; i++) {
338
+ if (this.jobIds[i] === id) {
339
+ const readCb = this.jobCbs[i];
340
+ const writeCb = this.jobWriteCbs[i];
341
+ this.jobIds.splice(i, 1);
342
+ this.jobCbs.splice(i, 1);
343
+ this.jobWriteCbs.splice(i, 1);
344
+ if (readCb !== nullRead) {
345
+ if (ok) readCb(null, payload);
346
+ else readCb(payload, "");
347
+ } else {
348
+ writeCb(ok ? null : payload);
349
+ }
350
+ return;
351
+ }
352
+ }
353
+ }
354
+
355
+ /** @remarks Not on iOS yet; reports through the callback. */
356
+ openFileDialog(
357
+ _options: OpenDialogOptions,
358
+ cb: (paths: string[] | null, err?: string) => void,
359
+ ): void {
360
+ cb(null, pending("openFileDialog", "iOS needs a document picker"));
361
+ }
362
+
363
+ /** @remarks Not on iOS yet; reports through the callback. */
364
+ saveFileDialog(
365
+ _options: SaveDialogOptions,
366
+ cb: (path: string | null, err?: string) => void,
367
+ ): void {
368
+ cb(null, pending("saveFileDialog", "iOS needs a document picker"));
369
+ }
370
+
371
+ /** @remarks No-op on iOS: an app has no window title to set. */
372
+ setTitle(_title: string): void {
373
+ console.error(pending("setTitle", "an iOS app has no window title"));
374
+ }
375
+
376
+ /** @remarks No-op on iOS: an app fills the screen. */
377
+ setSize(_width: number, _height: number, _hint?: number): void {
378
+ console.error(pending("setSize", "an iOS app fills the screen"));
379
+ }
380
+
381
+ /** @remarks No-op on iOS: an app is always fullscreen. */
382
+ setFullscreen(_on: boolean): void {
383
+ console.error(pending("setFullscreen", "an iOS app is always fullscreen"));
384
+ }
385
+
386
+ /** @remarks No-op on iOS: apps are dismissed by the user, not by code. */
387
+ quit(): void {
388
+ console.error(pending("quit", "iOS apps are dismissed by the user"));
389
+ }
390
+
391
+ /**
392
+ * Present for source compatibility with the desktop lane; UIKit owns the run
393
+ * loop here, so the shell shows the page rather than this.
394
+ */
395
+ run(html: string): number {
396
+ this.setHtml(html);
397
+ return 0;
398
+ }
399
+ }
400
+
401
+ // The public host types live in ./types (shipped as `janela/host` too, so a
402
+ // user's editor can see them). Re-exported here because the compiled build
403
+ // resolves them through this module — see the specifier rewrite in the CLI.
404
+ export type {
405
+ ArgsOf,
406
+ AsyncCommandHandler,
407
+ CommandHandler,
408
+ CommandShape,
409
+ CommandShapes,
410
+ CommandSpec,
411
+ CommandSpecs,
412
+ Commands,
413
+ Norm,
414
+ ResultOf,
415
+ DialogFilter,
416
+ Events,
417
+ FsCallback,
418
+ OpenDialogOptions,
419
+ SaveDialogOptions,
420
+ WindowConfig,
421
+ } from "./types";
422
+
423
+ export { defineCommands, defineEvents } from "./types";
424
+
425
+ /**
426
+ * A running janela app, typed by the contract it serves. See the desktop lane
427
+ * for the full explanation; the alias exists so a contract may be written as
428
+ * plain function types.
429
+ */
430
+ export type JanelaApp<
431
+ C extends CommandSpecs = CommandShapes,
432
+ E = Record<string, unknown>,
433
+ > = JanelaAppImpl<Norm<C>, E>;
434
+
435
+ export function createApp<
436
+ C extends CommandShapes = CommandShapes,
437
+ E = Record<string, unknown>,
438
+ >(cfg: WindowConfig): JanelaAppImpl<C, E> {
439
+ return new JanelaAppImpl<C, E>(cfg);
440
+ }
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // Deprecated standalone registrars (0.5.x)
444
+ // ---------------------------------------------------------------------------
445
+
446
+ /** @deprecated Use `app.command(name, handler)` on a contract-typed app. */
447
+ export function on<M extends CommandShapes, K extends keyof M & string>(
448
+ app: JanelaAppImpl,
449
+ _commands: unknown,
450
+ name: K,
451
+ handler: (args: M[K]["args"]) => M[K]["result"],
452
+ ): void {
453
+ app.command(name, (args: unknown) => handler(args as M[K]["args"]));
454
+ }
455
+
456
+ /** @deprecated Use `app.commandAsync(name, handler)` on a contract-typed app. */
457
+ export function onAsync<M extends CommandShapes, K extends keyof M & string>(
458
+ app: JanelaAppImpl,
459
+ _commands: unknown,
460
+ name: K,
461
+ handler: (
462
+ args: M[K]["args"],
463
+ resolve: (value: M[K]["result"]) => void,
464
+ reject: (reason: unknown) => void,
465
+ ) => void,
466
+ ): void {
467
+ app.commandAsync(
468
+ name,
469
+ (args: unknown, resolve: (v: unknown) => void, reject: (r: unknown) => void) => {
470
+ handler(args as M[K]["args"], (value: M[K]["result"]) => resolve(value), reject);
471
+ },
472
+ );
473
+ }
474
+
475
+ /** @deprecated Use `app.emit(event, payload)` on a contract-typed app. */
476
+ export function emit<E, K extends keyof E & string>(
477
+ app: JanelaAppImpl,
478
+ _events: unknown,
479
+ name: K,
480
+ payload: E[K],
481
+ ): void {
482
+ app.emit(name, payload as unknown);
483
+ }
@@ -0,0 +1,386 @@
1
+ // janela's iOS shell: UIKit owns the run loop, WKWebView is the window, and
2
+ // the app's TypeScript is a linked scriptc library we call into.
3
+ //
4
+ // This is the mirror image of the desktop shim. There, TypeScript owns main()
5
+ // and drives a C library over FFI; here the platform owns main() and calls a
6
+ // TypeScript library. scriptc builds iOS as a library only, and library mode
7
+ // links no event loop (SC4005), so there is nothing to pump: each invoke runs
8
+ // to completion and returns.
9
+ //
10
+ // The WKWebView / WKUserContentController / script-message-handler wiring
11
+ // follows the approach used by wry (https://github.com/tauri-apps/wry,
12
+ // Apache-2.0, © Tauri Programme within The Commons Conservancy) in
13
+ // src/wkwebview/. wry attaches its webview to a UIView supplied by tao; janela
14
+ // has no tao, so it creates the UIWindow and root view controller itself. The
15
+ // IPC envelope and the page-side bridge are janela's own, and match what the
16
+ // desktop shim injects so that `janela/api` works unchanged.
17
+
18
+ #import <UIKit/UIKit.h>
19
+ #import <WebKit/WebKit.h>
20
+
21
+ #include <string.h>
22
+
23
+ // ---- the scriptc library's C ABI (see the generated profile) ---------------
24
+ extern "C" {
25
+ void jl_init(void);
26
+ void jl_reset(void);
27
+ void jl_set_panic_sink(void (*fn)(void *, const char *, size_t, const char *, size_t), void *ctx);
28
+ int32_t jl_set_callback(const char *name, void (*fn)(void), void *ctx);
29
+ void jl_handle_invoke(const char *cmd, size_t cmd_len, const char *args, size_t args_len,
30
+ char **out, size_t *out_len);
31
+ void jl_index_html(char **out, size_t *out_len);
32
+ void jl_on_timer(double id);
33
+ void jl_on_fs_done(double id, bool ok, const char *payload, size_t payload_len);
34
+ }
35
+
36
+ static WKWebView *gWebView = nil;
37
+
38
+ /// Settle one page-side promise with an envelope that is already JSON.
39
+ static void settlePage(NSNumber *callId, NSString *envelope) {
40
+ NSString *js =
41
+ [NSString stringWithFormat:@"window.__janelaSettle(%@,%@);", callId, envelope];
42
+ dispatch_async(dispatch_get_main_queue(), ^{
43
+ [gWebView evaluateJavaScript:js completionHandler:nil];
44
+ });
45
+ }
46
+
47
+ static void panicSink(void *ctx, const char *sym, size_t symlen, const char *msg, size_t msglen) {
48
+ (void)ctx;
49
+ NSLog(@"[janela] host panic in %.*s: %.*s", (int)symlen, sym, (int)msglen, msg);
50
+ }
51
+
52
+ // ---- what the shell owns for the library ----------------------------------
53
+ //
54
+ // Two tables, mirroring the desktop shim: continuations the library parked
55
+ // with us, and page replies we are holding until the library says what they
56
+ // are. Both live on the main queue and are only touched there.
57
+ //
58
+ // THE RULE (upstream #263): a channel handler must never re-enter the library.
59
+ // Every handler below therefore only records what it was told and returns; the
60
+ // re-entry happens from a dispatch_async block, i.e. at the top of a later
61
+ // turn with no library frame beneath it. A breach silently appears to work, so
62
+ // this has to hold by construction rather than by testing.
63
+
64
+ /// Page replies whose answer was not ready when handle_invoke returned.
65
+ /// pendingId -> the page-side call id we have not settled yet.
66
+ static NSMutableDictionary<NSNumber *, NSNumber *> *gHeld = nil;
67
+ /// Envelopes that arrived before we knew the page call id — a command that
68
+ /// resolves synchronously settles during its own dispatch, before the
69
+ /// {"pending":id} envelope has made it back to us.
70
+ static NSMutableDictionary<NSNumber *, NSString *> *gEarly = nil;
71
+
72
+ /// Answer the page for a pending id, whichever half arrived first.
73
+ static void resolvePending(NSNumber *pendingId, NSString *envelope) {
74
+ NSNumber *callId = gHeld[pendingId];
75
+ if (callId) {
76
+ [gHeld removeObjectForKey:pendingId];
77
+ settlePage(callId, envelope);
78
+ } else {
79
+ gEarly[pendingId] = envelope; // handle_invoke has not returned yet
80
+ }
81
+ }
82
+
83
+ /// Register the page call id a pending answer belongs to.
84
+ static void holdPending(NSNumber *pendingId, NSNumber *callId) {
85
+ NSString *early = gEarly[pendingId];
86
+ if (early) {
87
+ [gEarly removeObjectForKey:pendingId];
88
+ settlePage(callId, early); // it resolved synchronously; answer now
89
+ } else {
90
+ gHeld[pendingId] = callId;
91
+ }
92
+ }
93
+
94
+ // ---- channels the library calls (TS -> shell) ------------------------------
95
+
96
+ /// Park a continuation with us and call back when it comes due. A zero delay
97
+ /// still goes through dispatch_after(0), which posts to the next turn — that
98
+ /// is app.defer(), and it costs no timer.
99
+ static void hostSchedule(void *ctx, double id, double ms) {
100
+ (void)ctx;
101
+ int64_t delay = (int64_t)(ms > 0 ? ms : 0);
102
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay * NSEC_PER_MSEC),
103
+ dispatch_get_main_queue(), ^{
104
+ jl_on_timer(id); // fresh turn: no library frame beneath us
105
+ });
106
+ }
107
+
108
+ /// The library has an answer for a held page reply.
109
+ static void hostSettle(void *ctx, double pendingId, const char *env, size_t env_len) {
110
+ (void)ctx;
111
+ NSString *envelope = [[NSString alloc] initWithBytes:env
112
+ length:env_len
113
+ encoding:NSUTF8StringEncoding] ?: @"null";
114
+ NSNumber *key = @((int64_t)pendingId);
115
+ dispatch_async(dispatch_get_main_queue(), ^{
116
+ resolvePending(key, envelope);
117
+ });
118
+ }
119
+
120
+ /// The file queue: blocking I/O happens here, never on the main queue, and
121
+ /// never inside the library. The library links no threads of its own, so this
122
+ /// is the only place a read can go.
123
+ /// An iOS app has no useful working directory and cannot see host paths, so a
124
+ /// relative path is taken as relative to the app's Documents directory — the
125
+ /// one place a sandboxed app can freely read and write.
126
+ static NSString *resolvePath(NSString *path) {
127
+ if ([path hasPrefix:@"/"]) return path;
128
+ NSArray<NSString *> *dirs =
129
+ NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
130
+ NSString *docs = dirs.firstObject ?: NSTemporaryDirectory();
131
+ return [docs stringByAppendingPathComponent:path];
132
+ }
133
+
134
+ static dispatch_queue_t fsQueue(void) {
135
+ static dispatch_queue_t q;
136
+ static dispatch_once_t once;
137
+ dispatch_once(&once, ^{
138
+ q = dispatch_queue_create("dev.janela.fs", DISPATCH_QUEUE_SERIAL);
139
+ });
140
+ return q;
141
+ }
142
+
143
+ static void finishFs(double id, bool ok, NSString *payload) {
144
+ // Convert INSIDE the block: -UTF8String hands back an autoreleased buffer
145
+ // whose lifetime does not survive the hop to another queue. The NSString is
146
+ // captured (and retained) by the block; the bytes are taken on arrival.
147
+ dispatch_async(dispatch_get_main_queue(), ^{
148
+ const char *bytes = payload.UTF8String ?: "";
149
+ jl_on_fs_done(id, ok, bytes, strlen(bytes));
150
+ });
151
+ }
152
+
153
+ static void hostReadFile(void *ctx, double id, const char *path, size_t path_len) {
154
+ (void)ctx;
155
+ NSString *p = resolvePath([[NSString alloc] initWithBytes:path
156
+ length:path_len
157
+ encoding:NSUTF8StringEncoding] ?: @"");
158
+ dispatch_async(fsQueue(), ^{
159
+ NSError *err = nil;
160
+ NSString *text = [NSString stringWithContentsOfFile:p
161
+ encoding:NSUTF8StringEncoding
162
+ error:&err];
163
+ if (text) finishFs(id, true, text);
164
+ else finishFs(id, false, err.localizedDescription ?: @"read failed");
165
+ });
166
+ }
167
+
168
+ static void hostWriteFile(void *ctx, double id, const char *path, size_t path_len,
169
+ const char *data, size_t data_len) {
170
+ (void)ctx;
171
+ NSString *p = resolvePath([[NSString alloc] initWithBytes:path
172
+ length:path_len
173
+ encoding:NSUTF8StringEncoding] ?: @"");
174
+ NSString *d = [[NSString alloc] initWithBytes:data
175
+ length:data_len
176
+ encoding:NSUTF8StringEncoding] ?: @"";
177
+ dispatch_async(fsQueue(), ^{
178
+ NSError *err = nil;
179
+ BOOL ok = [d writeToFile:p atomically:YES encoding:NSUTF8StringEncoding error:&err];
180
+ if (ok) finishFs(id, true, @"");
181
+ else finishFs(id, false, err.localizedDescription ?: @"write failed");
182
+ });
183
+ }
184
+
185
+ /// Copy a library-owned result out of its arena and release it. Results live
186
+ /// until the next jl_reset(), so nothing may hold the pointer past this call.
187
+ static NSString *takeResult(char *out, size_t out_len) {
188
+ NSString *s = out ? [[NSString alloc] initWithBytes:out
189
+ length:out_len
190
+ encoding:NSUTF8StringEncoding]
191
+ : nil;
192
+ jl_reset();
193
+ return s ?: @"null";
194
+ }
195
+
196
+ /// Escape a JSON document for splicing into a JS expression as a string
197
+ /// literal. Used for the event payload, which the page parses back.
198
+ static NSString *jsQuote(NSString *raw) {
199
+ NSData *d = [NSJSONSerialization dataWithJSONObject:@[ raw ] options:0 error:nil];
200
+ NSString *arr = [[NSString alloc] initWithData:d encoding:NSUTF8StringEncoding];
201
+ // ["..."] -> "..."
202
+ return [arr substringWithRange:NSMakeRange(1, arr.length - 2)];
203
+ }
204
+
205
+ // ---- host -> page: the event channel ---------------------------------------
206
+ //
207
+ // The library calls this through the declared callback channel whenever the
208
+ // app emits. Evaluating JS must happen on the main queue.
209
+
210
+ static void emitEvent(void *ctx, const char *name, size_t name_len,
211
+ const char *payload, size_t payload_len) {
212
+ (void)ctx;
213
+ NSString *event = [[NSString alloc] initWithBytes:name length:name_len
214
+ encoding:NSUTF8StringEncoding];
215
+ NSString *json = [[NSString alloc] initWithBytes:payload length:payload_len
216
+ encoding:NSUTF8StringEncoding];
217
+ if (!event || !json) return;
218
+ NSString *js = [NSString stringWithFormat:@"window.__wvEmit(%@,%@);", jsQuote(event), json];
219
+ dispatch_async(dispatch_get_main_queue(), ^{
220
+ [gWebView evaluateJavaScript:js completionHandler:nil];
221
+ });
222
+ }
223
+
224
+ // ---- page -> host: the invoke bridge ---------------------------------------
225
+
226
+ @interface JanelaBridge : NSObject <WKScriptMessageHandler>
227
+ @end
228
+
229
+ @implementation JanelaBridge
230
+
231
+ - (void)userContentController:(WKUserContentController *)controller
232
+ didReceiveScriptMessage:(WKScriptMessage *)message {
233
+ NSDictionary *body = (NSDictionary *)message.body;
234
+ if (![body isKindOfClass:NSDictionary.class]) return;
235
+
236
+ NSNumber *callId = body[@"id"];
237
+ NSString *cmd = body[@"cmd"];
238
+ NSString *args = body[@"args"] ?: @"null";
239
+ if (!callId || !cmd) return;
240
+
241
+ const char *c = cmd.UTF8String;
242
+ const char *a = args.UTF8String;
243
+ char *out = NULL;
244
+ size_t out_len = 0;
245
+ jl_handle_invoke(c, strlen(c), a, strlen(a), &out, &out_len);
246
+ NSString *reply = takeResult(out, out_len);
247
+
248
+ // An async command answers later: the library returns {"pending":<id>} and
249
+ // we hold the page's promise until hostSettle() tells us what it is.
250
+ NSData *replyData = [reply dataUsingEncoding:NSUTF8StringEncoding];
251
+ NSDictionary *env = replyData
252
+ ? [NSJSONSerialization JSONObjectWithData:replyData options:0 error:nil]
253
+ : nil;
254
+ NSNumber *pendingId = [env isKindOfClass:NSDictionary.class] ? env[@"pending"] : nil;
255
+ if (pendingId) {
256
+ holdPending(pendingId, callId);
257
+ return;
258
+ }
259
+
260
+ // Otherwise the reply is an envelope: {"ok":true,"value":…} or
261
+ // {"ok":false,"error":…}, already JSON, so it splices straight into the
262
+ // expression that settles the page-side promise.
263
+ settlePage(callId, reply);
264
+ }
265
+
266
+ @end
267
+
268
+ // ---- the page-side bridge --------------------------------------------------
269
+ //
270
+ // Same surface the desktop shim injects — window.janela.invoke/listen and
271
+ // window.__wvEmit — so `janela/api` and a project's frontend work unchanged.
272
+ // Only the transport differs: a script message instead of a webview bind.
273
+
274
+ static NSString *const kBootstrap =
275
+ @"window.__wvListeners = {};"
276
+ @"window.__janelaPending = {};"
277
+ @"window.__janelaSeq = 0;"
278
+ @"window.__janelaSettle = function (id, env) {"
279
+ @" var p = window.__janelaPending[id];"
280
+ @" if (!p) return;"
281
+ @" delete window.__janelaPending[id];"
282
+ @" if (env && env.ok) p.resolve(env.value); else p.reject(new Error((env && env.error) || 'janela: invoke failed'));"
283
+ @"};"
284
+ @"window.janela = {"
285
+ @" invoke: function (cmd, args) {"
286
+ @" var id = ++window.__janelaSeq;"
287
+ @" return new Promise(function (resolve, reject) {"
288
+ @" window.__janelaPending[id] = { resolve: resolve, reject: reject };"
289
+ @" window.webkit.messageHandlers.janela.postMessage({"
290
+ @" id: id, cmd: cmd, args: JSON.stringify(args === undefined ? null : args)"
291
+ @" });"
292
+ @" });"
293
+ @" },"
294
+ @" listen: function (event, cb) {"
295
+ @" if (!window.__wvListeners[event]) window.__wvListeners[event] = [];"
296
+ @" window.__wvListeners[event].push(cb);"
297
+ @" return function () {"
298
+ @" var a = window.__wvListeners[event] || [];"
299
+ @" var i = a.indexOf(cb);"
300
+ @" if (i >= 0) a.splice(i, 1);"
301
+ @" };"
302
+ @" },"
303
+ @"};"
304
+ @"window.__wvEmit = function (event, payload) {"
305
+ @" var cbs = window.__wvListeners[event] || [];"
306
+ @" for (var i = 0; i < cbs.length; i++) cbs[i](payload);"
307
+ @"};";
308
+
309
+ @interface JanelaViewController : UIViewController
310
+ @end
311
+
312
+ @implementation JanelaViewController {
313
+ JanelaBridge *_bridge;
314
+ }
315
+
316
+ - (void)viewDidLoad {
317
+ [super viewDidLoad];
318
+
319
+ WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
320
+ WKUserContentController *manager = config.userContentController;
321
+
322
+ _bridge = [[JanelaBridge alloc] init];
323
+ [manager addScriptMessageHandler:_bridge name:@"janela"];
324
+
325
+ // Injected before the document loads, exactly as the desktop shim does with
326
+ // webview_init — so the page can call janela.invoke from its first line.
327
+ WKUserScript *boot = [[WKUserScript alloc] initWithSource:kBootstrap
328
+ injectionTime:WKUserScriptInjectionTimeAtDocumentStart
329
+ forMainFrameOnly:YES];
330
+ [manager addUserScript:boot];
331
+
332
+ WKWebView *webview = [[WKWebView alloc] initWithFrame:self.view.bounds
333
+ configuration:config];
334
+ webview.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
335
+ [self.view addSubview:webview];
336
+ gWebView = webview;
337
+
338
+ char *out = NULL;
339
+ size_t out_len = 0;
340
+ jl_index_html(&out, &out_len);
341
+ NSString *html = takeResult(out, out_len);
342
+ [webview loadHTMLString:html baseURL:nil];
343
+ }
344
+
345
+ @end
346
+
347
+ @interface JanelaAppDelegate : UIResponder <UIApplicationDelegate>
348
+ @property(nonatomic, strong) UIWindow *window;
349
+ @end
350
+
351
+ @implementation JanelaAppDelegate
352
+
353
+ - (BOOL)application:(UIApplication *)application
354
+ didFinishLaunchingWithOptions:(NSDictionary *)options {
355
+ // Registration is a pure store and is legal before init; the panic sink and
356
+ // the event channel must both be in place before any TypeScript runs, since
357
+ // setup() executes during jl_init().
358
+ jl_set_panic_sink(panicSink, NULL);
359
+ gHeld = [NSMutableDictionary dictionary];
360
+ gEarly = [NSMutableDictionary dictionary];
361
+ // Registration is a pure store and is legal before init; it also survives
362
+ // jl_reset(), so it is done once here.
363
+ if (jl_set_callback("hostSchedule", (void (*)(void))hostSchedule, NULL) != 0 ||
364
+ jl_set_callback("hostSettle", (void (*)(void))hostSettle, NULL) != 0 ||
365
+ jl_set_callback("hostReadFile", (void (*)(void))hostReadFile, NULL) != 0 ||
366
+ jl_set_callback("hostWriteFile", (void (*)(void))hostWriteFile, NULL) != 0) {
367
+ NSLog(@"[janela] could not register a host channel");
368
+ }
369
+ if (jl_set_callback("janelaEmit", (void (*)(void))emitEvent, NULL) != 0) {
370
+ NSLog(@"[janela] could not register the event channel — app.emit will trap");
371
+ }
372
+ jl_init();
373
+
374
+ self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds];
375
+ self.window.rootViewController = [[JanelaViewController alloc] init];
376
+ [self.window makeKeyAndVisible];
377
+ return YES;
378
+ }
379
+
380
+ @end
381
+
382
+ int main(int argc, char *argv[]) {
383
+ @autoreleasepool {
384
+ return UIApplicationMain(argc, argv, nil, NSStringFromClass([JanelaAppDelegate class]));
385
+ }
386
+ }
@@ -6,5 +6,10 @@
6
6
  "title": "__NAME__",
7
7
  "width": 640,
8
8
  "height": 420
9
+ },
10
+ "ios": {
11
+ "identifier": "dev.janela.__NAME__",
12
+ "displayName": "__NAME__",
13
+ "minimumVersion": "15.0"
9
14
  }
10
15
  }
@@ -6,5 +6,10 @@
6
6
  "title": "__NAME__",
7
7
  "width": 640,
8
8
  "height": 420
9
+ },
10
+ "ios": {
11
+ "identifier": "dev.janela.__NAME__",
12
+ "displayName": "__NAME__",
13
+ "minimumVersion": "15.0"
9
14
  }
10
15
  }
@@ -6,5 +6,10 @@
6
6
  "title": "__NAME__",
7
7
  "width": 640,
8
8
  "height": 420
9
+ },
10
+ "ios": {
11
+ "identifier": "dev.janela.__NAME__",
12
+ "displayName": "__NAME__",
13
+ "minimumVersion": "15.0"
9
14
  }
10
15
  }
@@ -6,5 +6,10 @@
6
6
  "title": "__NAME__",
7
7
  "width": 640,
8
8
  "height": 420
9
+ },
10
+ "ios": {
11
+ "identifier": "dev.janela.__NAME__",
12
+ "displayName": "__NAME__",
13
+ "minimumVersion": "15.0"
9
14
  }
10
15
  }
@@ -6,5 +6,10 @@
6
6
  "title": "__NAME__",
7
7
  "width": 640,
8
8
  "height": 420
9
+ },
10
+ "ios": {
11
+ "identifier": "dev.janela.__NAME__",
12
+ "displayName": "__NAME__",
13
+ "minimumVersion": "15.0"
9
14
  }
10
15
  }