janela 0.8.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
@@ -279,9 +279,10 @@ in scriptc and can cost far more than the read did.
279
279
  **Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
280
280
  as long as the program sits inside the `run()` FFI call, so `setTimeout`,
281
281
  `queueMicrotask` and `await` in host code never fire while the window is open
282
- (they all run after it closes). janela supplies its own loop instead: a native
283
- ticker posts work to the UI thread via `webview_dispatch`, and it only runs
284
- while something is queued, so an idle app costs nothing.
282
+ (they all run after it closes). janela schedules through the shell instead: the
283
+ runtime parks a continuation under an id, the shell keeps the clock and calls
284
+ it back on the UI thread when it comes due. Nothing polls, so an idle app
285
+ costs nothing at all.
285
286
 
286
287
  **Still single-threaded.** scriptc's runtime is not thread-safe (concurrent
287
288
  calls from several threads abort the process), so host code always runs on the
@@ -541,10 +542,28 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
541
542
  - `console.log` from commands goes to stdout — visible under `janela dev`,
542
543
  not when launched from Finder.
543
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
+
544
562
  ## Status
545
563
 
546
564
  Early proof of concept, on macOS (arm64), Linux (WebKitGTK) and Windows
547
- (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
548
567
  [docs/findings.md](../../docs/findings.md). Not yet: async commands that run in
549
568
  parallel (host code is single-threaded; `commandAsync` interleaves instead),
550
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";
@@ -324,21 +529,21 @@ function ffiManifest(shimLib) {
324
529
  returns: "i32",
325
530
  },
326
531
  {
327
- name: "wvOnTick", symbol: "wv_on_tick",
532
+ name: "wvOnTimer", symbol: "wv_on_timer",
328
533
  params: [
329
534
  "i32",
330
- { callback: { id: "tick", params: [{ context: "tick" }], returns: "void", lifetime: "retained" } },
331
- { context: "tick" },
535
+ { callback: { id: "timer", params: ["i32", { context: "timer" }], returns: "void", lifetime: "retained" } },
536
+ { context: "timer" },
332
537
  ],
333
538
  returns: "i32",
334
539
  },
335
540
  { name: "wvRun", symbol: "wv_run", params: ["i32"], returns: "i32" },
336
541
  { name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
337
- // async: deferred returns + the UI-thread pump behind app.defer/sleep
542
+ // async: the held-reply table (deferred returns) plus shell-owned
543
+ // scheduling — TS parks a continuation id, the shell calls it back due.
338
544
  { name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
339
545
  { name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
340
- { name: "wvTickStart", symbol: "wv_tick_start", params: ["i32", "i32"], returns: "i32" },
341
- { name: "wvTickStop", symbol: "wv_tick_stop", params: ["i32"], returns: "i32" },
546
+ { name: "wvSchedule", symbol: "wv_schedule", params: ["i32", "i32", "i32"], returns: "i32" },
342
547
  // async file I/O: the blocking syscall runs on a shim worker thread
343
548
  { name: "wvFsRead", symbol: "wv_fs_read", params: ["i32", "string"], returns: "i32" },
344
549
  { name: "wvFsWrite", symbol: "wv_fs_write", params: ["i32", "string", "string"], returns: "i32" },
@@ -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.8.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"