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 +19 -1
- package/bin/janela.mjs +260 -12
- package/package.json +4 -3
- package/runtime/ios.ts +483 -0
- package/shim/ios/app.mm +386 -0
- package/templates/janela.conf.json +5 -0
- package/templates/react/files/janela.conf.json +5 -0
- package/templates/solid/files/janela.conf.json +5 -0
- package/templates/svelte/files/janela.conf.json +5 -0
- package/templates/vue/files/janela.conf.json +5 -0
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 |
|
|
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.
|
|
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
|
+
}
|
package/shim/ios/app.mm
ADDED
|
@@ -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
|
+
}
|