janela 0.10.1 → 0.11.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
@@ -559,6 +559,26 @@ clock and the file queue on both. File dialogs are not on iOS yet and report
559
559
  clearly when called; window control is a no-op there by nature. See
560
560
  [docs/ios.md](../../docs/ios.md).
561
561
 
562
+ ## Android
563
+
564
+ Same again: same `main.ts`, same contract, same frontend.
565
+
566
+ ```bash
567
+ janela build --target android # -> .janela/out-android/<name>.apk
568
+ janela dev --target android # build, boot an emulator, install, launch, follow logcat
569
+ ```
570
+
571
+ Needs a JDK, the Android SDK, the NDK and zig; there is no Gradle in the build.
572
+ Commands, events, `commandAsync`/`sleep`/`defer` and file I/O all behave as
573
+ they do on desktop and iOS — the shell owns the clock on each. Native dialogs
574
+ are not on Android yet and report clearly when called; `setTitle` sets the
575
+ Activity label and the other window controls are no-ops by nature. See
576
+ [docs/android.md](../../docs/android.md).
577
+
578
+ Unlike every other platform an APK also carries a little Java: the webview
579
+ backend needs a companion class, because `android.webkit.WebView` is a Java API
580
+ whose callbacks native code cannot receive on its own.
581
+
562
582
  ## Status
563
583
 
564
584
  Early proof of concept, on macOS (arm64), Linux (WebKitGTK) and Windows
package/bin/janela.mjs CHANGED
@@ -309,7 +309,7 @@ function iosConf(conf) {
309
309
  // The library profile: one export carries every command, so a project's own
310
310
  // commands need no ABI of their own. `janelaEmit` is the reverse channel the
311
311
  // shell registers before init.
312
- function iosProfile() {
312
+ function libraryProfile() {
313
313
  return {
314
314
  profile_format: 1,
315
315
  name: "janela",
@@ -400,7 +400,7 @@ function buildIos(root, conf, buildDir, outDir) {
400
400
  }
401
401
  const ios = iosConf(conf);
402
402
 
403
- writeFileSync(join(buildDir, "profile.json"), JSON.stringify(iosProfile(), null, 2) + "\n");
403
+ writeFileSync(join(buildDir, "profile.json"), JSON.stringify(libraryProfile(), null, 2) + "\n");
404
404
 
405
405
  console.log("janela: compiling TypeScript to an iOS library");
406
406
  run(["node", scriptcBin(), "build", "--lib", "--profile", "profile.json"], {
@@ -463,6 +463,264 @@ async function devIos(root) {
463
463
  ]);
464
464
  }
465
465
 
466
+
467
+ // ---- Android ---------------------------------------------------------------
468
+ //
469
+ // Android is library-mode like iOS: the system owns the Activity and its
470
+ // Looper, and the app's TypeScript is a linked scriptc library the shell calls
471
+ // into. Unlike every other target the APK also carries Java — the webview
472
+ // backend needs a companion class because android.webkit.WebView is a Java API
473
+ // and native code cannot define a class to receive its callbacks.
474
+
475
+ const ANDROID_MIN_SDK = 26;
476
+ const ANDROID_TARGET_SDK = 34;
477
+ const ANDROID_ABI = "arm64-v8a";
478
+
479
+ /// Android package names are Java package names: dot-separated identifiers,
480
+ /// so no hyphens. A janela project may be called `my-app`, which makes the
481
+ /// default identifier `dev.janela.my-app` — legal everywhere else and not
482
+ /// here, so each segment is coerced rather than failing the build.
483
+ function androidPackage(id) {
484
+ return id
485
+ .split(".")
486
+ .map((seg) => {
487
+ const cleaned = seg.replace(/[^A-Za-z0-9_]/g, "_");
488
+ return /^[A-Za-z_]/.test(cleaned) ? cleaned : `_${cleaned}`;
489
+ })
490
+ .join(".");
491
+ }
492
+
493
+ function androidConf(conf) {
494
+ const a = conf.android ?? {};
495
+ return {
496
+ applicationId: androidPackage(a.applicationId ?? a.identifier ?? conf.identifier),
497
+ label: a.label ?? conf.window?.title ?? conf.name,
498
+ minSdk: String(a.minSdk ?? ANDROID_MIN_SDK),
499
+ device: a.device ?? null,
500
+ };
501
+ }
502
+
503
+ /// The SDK pieces an Android build needs, or a message saying which is absent.
504
+ function androidSdk() {
505
+ const home =
506
+ process.env.ANDROID_HOME ??
507
+ process.env.ANDROID_SDK_ROOT ??
508
+ join(process.env.HOME ?? "", "Library", "Android", "sdk");
509
+ if (!existsSync(home)) {
510
+ fail(
511
+ "Android builds need the Android SDK. Install it (Android Studio, or " +
512
+ "`sdkmanager`) and set ANDROID_HOME",
513
+ );
514
+ }
515
+ const ndkRoot = process.env.ANDROID_NDK_ROOT ?? null;
516
+ const ndks = existsSync(join(home, "ndk"))
517
+ ? readdirSync(join(home, "ndk")).sort()
518
+ : [];
519
+ const ndk = ndkRoot ?? (ndks.length ? join(home, "ndk", ndks[ndks.length - 1]) : null);
520
+ if (!ndk || !existsSync(ndk)) {
521
+ fail(
522
+ "Android builds need the NDK: `sdkmanager --install 'ndk;27.0.12077973'`, " +
523
+ "or set ANDROID_NDK_ROOT",
524
+ );
525
+ }
526
+ const buildToolsDir = join(home, "build-tools");
527
+ const versions = existsSync(buildToolsDir) ? readdirSync(buildToolsDir).sort() : [];
528
+ if (!versions.length) fail("Android builds need build-tools: `sdkmanager --install 'build-tools;36.0.0'`");
529
+ const bt = join(buildToolsDir, versions[versions.length - 1]);
530
+
531
+ const platformsDir = join(home, "platforms");
532
+ const platforms = existsSync(platformsDir) ? readdirSync(platformsDir).sort() : [];
533
+ if (!platforms.length) fail("Android builds need a platform: `sdkmanager --install 'platforms;android-36'`");
534
+ const androidJar = join(platformsDir, platforms[platforms.length - 1], "android.jar");
535
+
536
+ // The NDK ships darwin-x86_64 host binaries even on Apple silicon.
537
+ const hosts = readdirSync(join(ndk, "toolchains", "llvm", "prebuilt"));
538
+ const toolchain = join(ndk, "toolchains", "llvm", "prebuilt", hosts[0], "bin");
539
+
540
+ return { home, ndk, bt, androidJar, toolchain, adb: join(home, "platform-tools", "adb") };
541
+ }
542
+
543
+ function javaHome() {
544
+ if (process.env.JAVA_HOME) return process.env.JAVA_HOME;
545
+ const brew = "/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home";
546
+ if (existsSync(brew)) return brew;
547
+ fail("Android builds need a JDK. Install one (`brew install openjdk`) and set JAVA_HOME");
548
+ }
549
+
550
+ function androidManifest(conf) {
551
+ const a = androidConf(conf);
552
+ return `<?xml version="1.0" encoding="utf-8"?>
553
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
554
+ package="${a.applicationId}"
555
+ android:versionCode="1"
556
+ android:versionName="${conf.version ?? "0.1.0"}">
557
+ <uses-permission android:name="android.permission.INTERNET"/>
558
+ <application android:label="${a.label}" android:hasCode="true">
559
+ <activity android:name="dev.janela.host.JanelaActivity" android:exported="true">
560
+ <intent-filter>
561
+ <action android:name="android.intent.action.MAIN"/>
562
+ <category android:name="android.intent.category.LAUNCHER"/>
563
+ </intent-filter>
564
+ </activity>
565
+ </application>
566
+ </manifest>
567
+ `;
568
+ }
569
+
570
+ /// A debug keystore, generated once and cached. Android refuses to install an
571
+ /// unsigned APK; a debug key is enough for a simulator or a developer device.
572
+ function debugKeystore(cacheDir, jdk) {
573
+ const ks = join(cacheDir, "debug.keystore");
574
+ if (existsSync(ks)) return ks;
575
+ run([
576
+ join(jdk, "bin", "keytool"), "-genkeypair", "-keystore", ks,
577
+ "-storepass", "android", "-keypass", "android", "-alias", "androiddebugkey",
578
+ "-dname", "CN=Android Debug,O=janela,C=US",
579
+ "-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
580
+ ]);
581
+ return ks;
582
+ }
583
+
584
+ function buildAndroid(root, conf, buildDir, outDir) {
585
+ const sdk = androidSdk();
586
+ const jdk = javaHome();
587
+ if (!which("zig")) {
588
+ fail(
589
+ "Android builds need zig on PATH: scriptc routes mobile targets through " +
590
+ "`zig cc`. Install it with `brew install zig`",
591
+ );
592
+ }
593
+ const a = androidConf(conf);
594
+ const cacheDir = join(root, ".janela", "cache");
595
+
596
+ writeFileSync(join(buildDir, "profile.json"), JSON.stringify(libraryProfile(), null, 2) + "\n");
597
+
598
+ console.log("janela: compiling TypeScript to an Android library");
599
+ run(["node", scriptcBin(), "build", "--lib", "--profile", "profile.json"], {
600
+ cwd: buildDir,
601
+ env: {
602
+ ...process.env,
603
+ SCRIPTC_CC: "zigcc",
604
+ SCRIPTC_TARGET: "aarch64-linux-android",
605
+ ANDROID_NDK_ROOT: sdk.ndk,
606
+ },
607
+ });
608
+ const lib = join(buildDir, ".scriptc", "entry.lib.a");
609
+ if (!existsSync(lib)) fail(`scriptc produced no library at ${lib}`);
610
+
611
+ // The shell is a shared library the Activity loads; Android has no main().
612
+ console.log("janela: compiling the Android shell");
613
+ const stage = join(buildDir, "apk");
614
+ const jniDir = join(stage, "lib", ANDROID_ABI);
615
+ mkdirSync(jniDir, { recursive: true });
616
+ const so = join(jniDir, "libjanela.so");
617
+ run([
618
+ join(sdk.toolchain, `aarch64-linux-android${a.minSdk}-clang++`),
619
+ join(KIT, "shim", "android", "app.cc"),
620
+ "-shared", "-fPIC", "-std=c++17", "-O2",
621
+ // The NDK links libc++ dynamically by default, which would mean shipping
622
+ // libc++_shared.so beside ours; static keeps the APK to one library.
623
+ "-static-libstdc++",
624
+ `-I${join(KIT, "vendor-webview", "core", "include")}`,
625
+ lib, "-llog", "-o", so,
626
+ ]);
627
+ run([join(sdk.toolchain, "llvm-strip"), so]);
628
+
629
+ // The companion Java class the backend requires, plus janela's Activity.
630
+ console.log("janela: compiling the Java bridge");
631
+ const classes = join(buildDir, "classes");
632
+ mkdirSync(classes, { recursive: true });
633
+ const javaSrc = join(KIT, "shim", "android", "java");
634
+ const sources = [
635
+ join(javaSrc, "dev", "webview", "WebviewBridge.java"),
636
+ join(javaSrc, "dev", "janela", "host", "JanelaActivity.java"),
637
+ ];
638
+ run([
639
+ join(jdk, "bin", "javac"), "-source", "11", "-target", "11", "-nowarn",
640
+ "-classpath", sdk.androidJar, "-d", classes, ...sources,
641
+ ]);
642
+ // d8 needs every class file named, including the inner classes javac
643
+ // emitted for the @JavascriptInterface object and the WebViewClient.
644
+ const classFiles = [];
645
+ const collect = (dir) => {
646
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
647
+ const p = join(dir, entry.name);
648
+ if (entry.isDirectory()) collect(p);
649
+ else if (entry.name.endsWith(".class")) classFiles.push(p);
650
+ }
651
+ };
652
+ collect(classes);
653
+ run([join(sdk.bt, "d8"), "--min-api", a.minSdk, "--output", stage, ...classFiles]);
654
+
655
+ writeFileSync(join(buildDir, "AndroidManifest.xml"), androidManifest(conf));
656
+
657
+ console.log("janela: packaging the APK");
658
+ const unsigned = join(buildDir, "unsigned.apk");
659
+ run([
660
+ join(sdk.bt, "aapt2"), "link", "-o", unsigned, "-I", sdk.androidJar,
661
+ "--manifest", join(buildDir, "AndroidManifest.xml"),
662
+ "--min-sdk-version", a.minSdk,
663
+ "--target-sdk-version", String(ANDROID_TARGET_SDK),
664
+ ]);
665
+ // aapt2 emits the manifest and resources; the code and the shared library
666
+ // are added to the same zip afterwards.
667
+ run(["zip", "-q", "-r", unsigned, "classes.dex", "lib"], { cwd: stage });
668
+
669
+ const aligned = join(buildDir, "aligned.apk");
670
+ run([join(sdk.bt, "zipalign"), "-f", "4", unsigned, aligned]);
671
+ const apk = join(outDir, `${conf.name}.apk`);
672
+ run([
673
+ join(sdk.bt, "apksigner"), "sign",
674
+ "--ks", debugKeystore(cacheDir, jdk),
675
+ "--ks-pass", "pass:android", "--key-pass", "pass:android",
676
+ "--out", apk, aligned,
677
+ ]);
678
+
679
+ console.log(
680
+ `janela: built ${relative(root, apk)} ` +
681
+ `(${statSync(apk).size} bytes, ${ANDROID_ABI}; .so ${statSync(so).size} bytes)`,
682
+ );
683
+ return apk;
684
+ }
685
+
686
+ /// Boots an emulator if none is running, then installs and launches.
687
+ async function devAndroid(root) {
688
+ const sdk = androidSdk();
689
+ const conf = loadConf(root);
690
+ const a = androidConf(conf);
691
+ const apk = build(root, { target: "android" });
692
+
693
+ const devices = capture([sdk.adb, "devices"]).split("\n").slice(1)
694
+ .filter((l) => l.trim().endsWith("device"));
695
+ if (!devices.length) {
696
+ const avds = capture([join(sdk.home, "emulator", "emulator"), "-list-avds"])
697
+ .split("\n").map((s) => s.trim()).filter(Boolean);
698
+ if (!avds.length) {
699
+ fail("no Android device or emulator. Create an AVD in Android Studio, or attach a device");
700
+ }
701
+ const avd = a.device ?? avds[0];
702
+ console.log(`janela: booting ${avd}`);
703
+ spawn(join(sdk.home, "emulator", "emulator"), ["-avd", avd, "-no-snapshot-save"], {
704
+ detached: true, stdio: "ignore",
705
+ }).unref();
706
+ run([sdk.adb, "wait-for-device"]);
707
+ // wait-for-device returns as soon as adb can talk to it; the package
708
+ // manager is not up until the boot animation has finished.
709
+ for (;;) {
710
+ const done = capture([sdk.adb, "shell", "getprop", "sys.boot_completed"]).trim();
711
+ if (done === "1") break;
712
+ await new Promise((r) => setTimeout(r, 2000));
713
+ }
714
+ }
715
+
716
+ console.log("janela: installing");
717
+ run([sdk.adb, "install", "-r", apk]);
718
+ run([sdk.adb, "shell", "am", "start", "-n",
719
+ `${a.applicationId}/dev.janela.host.JanelaActivity`]);
720
+ console.log("janela: running (Ctrl-C to stop following the log)");
721
+ run([sdk.adb, "logcat", "-s", "janela:*", "chromium:*", "AndroidRuntime:*"]);
722
+ }
723
+
466
724
  // ---- shim ----------------------------------------------------------------
467
725
 
468
726
  function which(bin) {
@@ -680,20 +938,28 @@ function makeGuiSubsystem(exePath) {
680
938
  function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
681
939
  const conf = loadConf(root);
682
940
  const ios = target === "ios";
683
- const buildDir = join(root, ".janela", ios ? "build-ios" : "build");
941
+ const android = target === "android";
942
+ // Both mobile targets are library-mode: the platform owns the loop and the
943
+ // shell calls into a linked scriptc library. Everything above the shell is
944
+ // shared between them.
945
+ const mobile = ios || android;
946
+ const suffix = ios ? "-ios" : android ? "-android" : "";
947
+ const buildDir = join(root, ".janela", `build${suffix}`);
684
948
  const cacheDir = join(root, ".janela", "cache");
685
- const outDir = join(root, ".janela", ios ? "out-ios" : "out");
949
+ const outDir = join(root, ".janela", `out${suffix}`);
686
950
  for (const d of [buildDir, cacheDir, outDir]) mkdirSync(d, { recursive: true });
687
951
 
688
952
  // Desktop links a C shim over webview.h; iOS links no shim at all — the
689
953
  // UIKit shell is the program, and it links this build's output instead.
690
- const shimLib = ios ? null : buildShim(cacheDir);
954
+ const shimLib = mobile ? null : buildShim(cacheDir);
691
955
 
692
956
  // Assemble the compile unit: runtime + user's commands + generated modules.
693
957
  // Which runtime lane lands here as "./janela" is the whole difference
694
958
  // between the two targets — a project's main.ts is compiled unchanged
695
959
  // against either.
696
- cpSync(join(KIT, "runtime", ios ? "ios.ts" : "janela.ts"), join(buildDir, "janela.ts"));
960
+ // ios.ts is the library-mode runtime: Android uses it unchanged, which is
961
+ // the point — the same TypeScript serves both mobile shells.
962
+ cpSync(join(KIT, "runtime", mobile ? "ios.ts" : "janela.ts"), join(buildDir, "janela.ts"));
697
963
  cpSync(join(KIT, "runtime", "types.ts"), join(buildDir, "types.ts"));
698
964
  const mainSrc = join(root, "src-host", "main.ts");
699
965
  if (!existsSync(mainSrc)) fail("missing src-host/main.ts");
@@ -728,7 +994,7 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
728
994
  // The iOS entry exports the library's two entry points instead of running a
729
995
  // loop: UIKit owns the loop and calls in. Everything above this line — the
730
996
  // contract, main.ts, the flattened frontend — is identical to desktop.
731
- const entryTail = ios
997
+ const entryTail = mobile
732
998
  ? `const app = createApp<CmdsOf<typeof setup>, EvtsOf<typeof setup>>(WINDOW);\n` +
733
999
  `setup(app);\n` +
734
1000
  `app.setHtml(INDEX_HTML);\n\n` +
@@ -774,6 +1040,7 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
774
1040
  );
775
1041
 
776
1042
  if (ios) return buildIos(root, conf, buildDir, outDir);
1043
+ if (android) return buildAndroid(root, conf, buildDir, outDir);
777
1044
 
778
1045
  writeFileSync(join(buildDir, "janela.ffi.json"), JSON.stringify(ffiManifest(shimLib), null, 2) + "\n");
779
1046
 
@@ -970,7 +1237,7 @@ function positionals() {
970
1237
  return out;
971
1238
  }
972
1239
 
973
- const TARGETS = ["desktop", "ios"];
1240
+ const TARGETS = ["desktop", "ios", "android"];
974
1241
 
975
1242
  function targetOrFail() {
976
1243
  const t = flag("target", "desktop");
@@ -986,14 +1253,18 @@ switch (cmd) {
986
1253
  build(process.cwd(), { target: targetOrFail() });
987
1254
  break;
988
1255
  case "dev":
989
- if (targetOrFail() === "ios") await devIos(process.cwd());
990
- else await dev(process.cwd());
1256
+ {
1257
+ const t = targetOrFail();
1258
+ if (t === "ios") await devIos(process.cwd());
1259
+ else if (t === "android") await devAndroid(process.cwd());
1260
+ else await dev(process.cwd());
1261
+ }
991
1262
  break;
992
1263
  default:
993
1264
  console.log(
994
1265
  "usage: janela init <name> [--template vanilla|vue|react|svelte|solid]\n" +
995
- " janela build [--target desktop|ios]\n" +
996
- " janela dev [--target desktop|ios]",
1266
+ " janela build [--target desktop|ios|android]\n" +
1267
+ " janela dev [--target desktop|ios|android]",
997
1268
  );
998
1269
  process.exit(cmd ? 1 : 0);
999
1270
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.10.1",
4
- "description": "Desktop and iOS apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
3
+ "version": "0.11.0",
4
+ "description": "Desktop, iOS and Android apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "janela": "bin/janela.mjs",
@@ -41,7 +41,9 @@
41
41
  "tauri",
42
42
  "electron-alternative",
43
43
  "scriptc",
44
- "ios"
44
+ "ios",
45
+ "android",
46
+ "mobile"
45
47
  ],
46
48
  "engines": {
47
49
  "node": ">=24"