janela 0.11.0 → 0.12.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
@@ -2,11 +2,26 @@
2
2
 
3
3
  > *janela* — Portuguese for **window**.
4
4
 
5
- Desktop apps in pure TypeScript, compiled to native. No Rust, no Node, no
6
- Electron. The backend is TypeScript compiled to a native binary by
5
+ Desktop and mobile apps in pure TypeScript, compiled to native. No Rust, no
6
+ Node, no Electron. The backend is TypeScript compiled to a native binary by
7
7
  [scriptc](https://scriptc.dev); the window is the OS webview via
8
- [webview/webview](https://github.com/webview/webview) (WKWebView on macOS,
9
- WebKitGTK on Linux). Binaries come out ~500 KB.
8
+ [webview/webview](https://github.com/webview/webview). Binaries come out
9
+ around 400–500 KB, with no bundled browser and no bundled runtime.
10
+
11
+ Five targets, one runtime — the same `main.ts`, the same typed contract and the
12
+ same frontend build for each:
13
+
14
+ | Platform | Webview | Build | Output |
15
+ |---|---|---|---|
16
+ | macOS | WKWebView | `janela build` | binary + `.app` |
17
+ | Linux | WebKitGTK | `janela build` | binary |
18
+ | Windows | WebView2 | `janela build` | `.exe` (GUI subsystem) |
19
+ | iOS | UIKit + WKWebView | `janela build --target ios` | simulator `.app` |
20
+ | Android | `android.webkit.WebView` | `janela build --target android` | `.apk` |
21
+
22
+ Commands, the typed contract, events, async commands and file I/O behave the
23
+ same on all five. Native file dialogs and runtime window control are
24
+ desktop-only for now; on mobile they report clearly when called.
10
25
 
11
26
  ## Quick start
12
27
 
@@ -31,8 +46,10 @@ framework, `janela dev` runs your Vite dev server and points the window at it,
31
46
  and `janela build` flattens the production bundle into the binary — see
32
47
  [docs/frontend.md](../../docs/frontend.md).
33
48
 
34
- Requirements: Node 18+, a C++ compiler (Xcode CLT on macOS; g++ +
35
- `libwebkit2gtk-4.1-dev` on Linux; see [Windows](#windows) below). A worked
49
+ Requirements: Node 24+ and a C++ toolchain for the platform you are building —
50
+ Xcode CLT on macOS; `g++` + `libwebkit2gtk-4.1-dev` on Linux; an llvm-mingw
51
+ clang on Windows (see [Windows](#windows) below). iOS additionally needs Xcode
52
+ and `zig`; Android needs a JDK, the Android SDK, the NDK and `zig`. A worked
36
53
  example lives in [`examples/demo`](examples/demo) — commands, events, and a
37
54
  file reader.
38
55
 
@@ -552,7 +569,8 @@ janela build --target ios # -> .janela/out-ios/<name>.app (simulator)
552
569
  janela dev --target ios # build, boot a simulator, install, launch
553
570
  ```
554
571
 
555
- It is **not part of a release yet** and is simulator-only. Commands, the typed
572
+ It is **simulator-only** so far device builds and code signing are not
573
+ wired up yet. Commands, the typed
556
574
  contract, events, Vite frontends, async commands (`commandAsync`, `defer`,
557
575
  `sleep`) and file I/O all work the same as on desktop — the shell owns the
558
576
  clock and the file queue on both. File dialogs are not on iOS yet and report
@@ -581,13 +599,17 @@ whose callbacks native code cannot receive on its own.
581
599
 
582
600
  ## Status
583
601
 
584
- Early proof of concept, on macOS (arm64), Linux (WebKitGTK) and Windows
585
- (WebView2), with iOS on a branch (above). The design notes and scriptc
586
- findings behind it are in
587
- [docs/findings.md](../../docs/findings.md). Not yet: async commands that run in
588
- parallel (host code is single-threaded; `commandAsync` interleaves instead),
589
- tray icons and menus, multi-window, directory picking on Windows,
590
- `app.center()`, and icons/installers/notarization.
602
+ Young and pre-1.0. Desktop (macOS arm64, Linux/WebKitGTK, Windows/WebView2) is
603
+ the most exercised path; iOS and Android are newer, and iOS is simulator-only.
604
+ The design notes and scriptc findings behind it are in
605
+ [docs/findings.md](../../docs/findings.md), with per-platform notes in
606
+ [docs/ios.md](../../docs/ios.md) and [docs/android.md](../../docs/android.md).
607
+
608
+ Not yet: native dialogs and window control on mobile; device builds and code
609
+ signing; icons, installers and notarization; async commands that run in
610
+ parallel (host code is single-threaded, so `commandAsync` interleaves and a
611
+ CPU-bound handler still needs slicing); an async HTTP client; tray icons and
612
+ menus; multi-window; directory picking on Windows; and `app.center()`.
591
613
 
592
614
  ## Releasing
593
615
 
package/bin/janela.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { spawn, spawnSync } from "node:child_process";
14
14
  import {
15
- cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync,
15
+ cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync,
16
16
  } from "node:fs";
17
17
  import { createServer } from "node:net";
18
18
  import { createRequire } from "node:module";
@@ -366,8 +366,15 @@ function libraryProfile() {
366
366
  };
367
367
  }
368
368
 
369
- function iosPlist(conf) {
369
+ function iosPlist(conf, iconFiles = []) {
370
370
  const ios = iosConf(conf);
371
+ // The asset-catalogue route needs actool; the CFBundleIconFiles list is the
372
+ // older mechanism and keeps this hand-assembled bundle toolchain-free.
373
+ const icons = iconFiles.length
374
+ ? `\n <key>CFBundleIconFiles</key><array>${iconFiles
375
+ .map((f) => `<string>${f}</string>`)
376
+ .join("")}</array>`
377
+ : "";
371
378
  return `<?xml version="1.0" encoding="UTF-8"?>
372
379
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
373
380
  <plist version="1.0">
@@ -382,7 +389,7 @@ function iosPlist(conf) {
382
389
  <key>LSRequiresIPhoneOS</key><true/>
383
390
  <key>UILaunchScreen</key><dict/>
384
391
  <key>MinimumOSVersion</key><string>${ios.minimumVersion}</string>
385
- <key>CFBundleSupportedPlatforms</key><array><string>iPhoneSimulator</string></array>
392
+ <key>CFBundleSupportedPlatforms</key><array><string>iPhoneSimulator</string></array>${icons}
386
393
  </dict>
387
394
  </plist>
388
395
  `;
@@ -433,7 +440,16 @@ function buildIos(root, conf, buildDir, outDir) {
433
440
  "-o", join(bundle, conf.name),
434
441
  ]);
435
442
  run(["strip", join(bundle, conf.name)]);
436
- writeFileSync(join(bundle, "Info.plist"), iosPlist(conf));
443
+
444
+ const icon = iconSource(root, conf);
445
+ let iconFiles = [];
446
+ if (icon) {
447
+ iconFiles = makeIosIcons(icon, bundle);
448
+ if (!iconFiles.length) {
449
+ console.warn("janela: could not generate iOS icons (sips unavailable) — building without one");
450
+ }
451
+ }
452
+ writeFileSync(join(bundle, "Info.plist"), iosPlist(conf, iconFiles));
437
453
 
438
454
  console.log(
439
455
  `janela: built ${relative(root, bundle)} ` +
@@ -547,15 +563,16 @@ function javaHome() {
547
563
  fail("Android builds need a JDK. Install one (`brew install openjdk`) and set JAVA_HOME");
548
564
  }
549
565
 
550
- function androidManifest(conf) {
566
+ function androidManifest(conf, { icon = false } = {}) {
551
567
  const a = androidConf(conf);
568
+ const iconAttr = icon ? ` android:icon="@mipmap/ic_launcher"` : "";
552
569
  return `<?xml version="1.0" encoding="utf-8"?>
553
570
  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
554
571
  package="${a.applicationId}"
555
572
  android:versionCode="1"
556
573
  android:versionName="${conf.version ?? "0.1.0"}">
557
574
  <uses-permission android:name="android.permission.INTERNET"/>
558
- <application android:label="${a.label}" android:hasCode="true">
575
+ <application android:label="${a.label}"${iconAttr} android:hasCode="true">
559
576
  <activity android:name="dev.janela.host.JanelaActivity" android:exported="true">
560
577
  <intent-filter>
561
578
  <action android:name="android.intent.action.MAIN"/>
@@ -652,7 +669,22 @@ function buildAndroid(root, conf, buildDir, outDir) {
652
669
  collect(classes);
653
670
  run([join(sdk.bt, "d8"), "--min-api", a.minSdk, "--output", stage, ...classFiles]);
654
671
 
655
- writeFileSync(join(buildDir, "AndroidManifest.xml"), androidManifest(conf));
672
+ // Launcher icons, if the project has one. aapt2 needs resources compiled to
673
+ // .flat before they can be linked, so this is a two-step detour.
674
+ const icon = iconSource(root, conf);
675
+ let resZip = null;
676
+ if (icon) {
677
+ const resDir = join(buildDir, "res");
678
+ rmSync(resDir, { recursive: true, force: true });
679
+ if (makeAndroidRes(icon, resDir)) {
680
+ resZip = join(buildDir, "res.zip");
681
+ run([join(sdk.bt, "aapt2"), "compile", "--dir", resDir, "-o", resZip]);
682
+ } else {
683
+ console.warn("janela: could not generate launcher icons (sips unavailable) — building without one");
684
+ }
685
+ }
686
+
687
+ writeFileSync(join(buildDir, "AndroidManifest.xml"), androidManifest(conf, { icon: Boolean(resZip) }));
656
688
 
657
689
  console.log("janela: packaging the APK");
658
690
  const unsigned = join(buildDir, "unsigned.apk");
@@ -661,6 +693,7 @@ function buildAndroid(root, conf, buildDir, outDir) {
661
693
  "--manifest", join(buildDir, "AndroidManifest.xml"),
662
694
  "--min-sdk-version", a.minSdk,
663
695
  "--target-sdk-version", String(ANDROID_TARGET_SDK),
696
+ ...(resZip ? [resZip] : []),
664
697
  ]);
665
698
  // aapt2 emits the manifest and resources; the code and the shared library
666
699
  // are added to the same zip afterwards.
@@ -669,12 +702,40 @@ function buildAndroid(root, conf, buildDir, outDir) {
669
702
  const aligned = join(buildDir, "aligned.apk");
670
703
  run([join(sdk.bt, "zipalign"), "-f", "4", unsigned, aligned]);
671
704
  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
- ]);
705
+ // A release keystore is the user's to own: janela never creates one and
706
+ // never reads a password from the config file. Point `bundle.androidKeystore`
707
+ // at a .jks and supply the passwords through the environment; absent that,
708
+ // the APK is signed with a throwaway debug key that Play Store will reject.
709
+ const ksConf = conf.bundle?.androidKeystore;
710
+ if (ksConf) {
711
+ const ksPath = resolve(root, ksConf.path ?? fail("bundle.androidKeystore needs a 'path'"));
712
+ if (!existsSync(ksPath)) fail(`bundle.androidKeystore.path does not exist: ${ksPath}`);
713
+ const storeEnv = ksConf.storePasswordEnv ?? "JANELA_ANDROID_STORE_PASSWORD";
714
+ const keyEnv = ksConf.keyPasswordEnv ?? storeEnv;
715
+ const storePass = process.env[storeEnv];
716
+ if (!storePass) {
717
+ fail(
718
+ `bundle.androidKeystore is configured but $${storeEnv} is not set.\n` +
719
+ " Export the keystore password in the environment; janela will not read it from a file.",
720
+ );
721
+ }
722
+ const keyPass = process.env[keyEnv] ?? storePass;
723
+ run([
724
+ join(sdk.bt, "apksigner"), "sign",
725
+ "--ks", ksPath,
726
+ ...(ksConf.alias ? ["--ks-key-alias", ksConf.alias] : []),
727
+ "--ks-pass", `pass:${storePass}`, "--key-pass", `pass:${keyPass}`,
728
+ "--out", apk, aligned,
729
+ ]);
730
+ console.log(`janela: signed with ${relative(root, ksPath)}`);
731
+ } else {
732
+ run([
733
+ join(sdk.bt, "apksigner"), "sign",
734
+ "--ks", debugKeystore(cacheDir, jdk),
735
+ "--ks-pass", "pass:android", "--key-pass", "pass:android",
736
+ "--out", apk, aligned,
737
+ ]);
738
+ }
678
739
 
679
740
  console.log(
680
741
  `janela: built ${relative(root, apk)} ` +
@@ -935,6 +996,151 @@ function makeGuiSubsystem(exePath) {
935
996
 
936
997
  // `devUrl` points the window at a running vite server instead of inlining the
937
998
  // frontend; `gui` asks for a GUI-subsystem .exe on Windows (build, not dev).
999
+ // ---- icons and packaging ---------------------------------------------------
1000
+ //
1001
+ // One square source image becomes whatever each platform wants. Everything
1002
+ // here is optional: a project with no icon configured and no icon.png builds
1003
+ // exactly as it did before, and a platform whose converter is unavailable is
1004
+ // skipped with a warning rather than failing the build.
1005
+ //
1006
+ // `sips` and `iconutil` ship with macOS, so icon generation currently requires
1007
+ // building on a Mac. That is already true of .app/.dmg/iOS output.
1008
+
1009
+ /// The configured icon, or `icon.png` beside janela.conf.json, or null.
1010
+ function iconSource(root, conf) {
1011
+ const named = conf.bundle?.icon ?? conf.icon;
1012
+ if (named) {
1013
+ const p = resolve(root, named);
1014
+ if (!existsSync(p)) fail(`bundle.icon '${named}' does not exist (resolved to ${p})`);
1015
+ return p;
1016
+ }
1017
+ const fallback = join(root, "icon.png");
1018
+ return existsSync(fallback) ? fallback : null;
1019
+ }
1020
+
1021
+ function haveTool(name) {
1022
+ return spawnSync("command", ["-v", name], { shell: true, stdio: "ignore" }).status === 0;
1023
+ }
1024
+
1025
+ /// Square PNG at `size`, written to `dst`. Returns false if sips is missing.
1026
+ function resizePng(src, dst, size) {
1027
+ if (!haveTool("sips")) return false;
1028
+ const r = spawnSync("sips", ["-z", String(size), String(size), src, "--out", dst], { stdio: "ignore" });
1029
+ return r.status === 0;
1030
+ }
1031
+
1032
+ const ICNS_SIZES = [16, 32, 64, 128, 256, 512, 1024];
1033
+
1034
+ /// macOS .icns via the iconset convention iconutil expects.
1035
+ function makeIcns(src, cacheDir, name) {
1036
+ if (!haveTool("iconutil") || !haveTool("sips")) return null;
1037
+ const iconset = join(cacheDir, `${name}.iconset`);
1038
+ rmSync(iconset, { recursive: true, force: true });
1039
+ mkdirSync(iconset, { recursive: true });
1040
+ // iconutil wants both @1x and @2x names; a 32px @2x is the 64px render.
1041
+ for (const s of ICNS_SIZES) {
1042
+ if (s <= 512) resizePng(src, join(iconset, `icon_${s}x${s}.png`), s);
1043
+ if (s >= 32) resizePng(src, join(iconset, `icon_${s / 2}x${s / 2}@2x.png`), s);
1044
+ }
1045
+ const icns = join(cacheDir, `${name}.icns`);
1046
+ const r = spawnSync("iconutil", ["-c", "icns", iconset, "-o", icns], { stdio: "ignore" });
1047
+ return r.status === 0 && existsSync(icns) ? icns : null;
1048
+ }
1049
+
1050
+ const ICO_SIZES = [16, 32, 48, 64, 128, 256];
1051
+
1052
+ /// Windows .ico. The format allows PNG payloads (Vista+), so this needs no
1053
+ /// image library: a 6-byte header, one 16-byte directory entry per image,
1054
+ /// then the PNG bytes.
1055
+ function makeIco(src, cacheDir, name) {
1056
+ if (!haveTool("sips")) return null;
1057
+ const pngs = [];
1058
+ for (const s of ICO_SIZES) {
1059
+ const p = join(cacheDir, `ico-${s}.png`);
1060
+ if (resizePng(src, p, s)) pngs.push({ size: s, data: readFileSync(p) });
1061
+ }
1062
+ if (!pngs.length) return null;
1063
+
1064
+ const header = Buffer.alloc(6);
1065
+ header.writeUInt16LE(0, 0); // reserved
1066
+ header.writeUInt16LE(1, 2); // type: icon
1067
+ header.writeUInt16LE(pngs.length, 4);
1068
+
1069
+ let offset = 6 + pngs.length * 16;
1070
+ const entries = [];
1071
+ for (const { size, data } of pngs) {
1072
+ const e = Buffer.alloc(16);
1073
+ e.writeUInt8(size >= 256 ? 0 : size, 0); // 0 means 256
1074
+ e.writeUInt8(size >= 256 ? 0 : size, 1);
1075
+ e.writeUInt8(0, 2); // palette
1076
+ e.writeUInt8(0, 3); // reserved
1077
+ e.writeUInt16LE(1, 4); // colour planes
1078
+ e.writeUInt16LE(32, 6); // bits per pixel
1079
+ e.writeUInt32LE(data.length, 8);
1080
+ e.writeUInt32LE(offset, 12);
1081
+ entries.push(e);
1082
+ offset += data.length;
1083
+ }
1084
+ const ico = join(cacheDir, `${name}.ico`);
1085
+ writeFileSync(ico, Buffer.concat([header, ...entries, ...pngs.map((p) => p.data)]));
1086
+ return ico;
1087
+ }
1088
+
1089
+ /// iOS icons. The modern route is a compiled asset catalogue; the older
1090
+ /// CFBundleIconFiles list still works and needs no actool, which keeps the
1091
+ /// hand-rolled bundle self-contained.
1092
+ const IOS_ICON_SIZES = [40, 58, 60, 80, 87, 120, 180, 1024];
1093
+
1094
+ function makeIosIcons(src, bundleDir) {
1095
+ if (!haveTool("sips")) return [];
1096
+ const names = [];
1097
+ for (const s of IOS_ICON_SIZES) {
1098
+ const base = `AppIcon${s}.png`;
1099
+ if (resizePng(src, join(bundleDir, base), s)) names.push(base);
1100
+ }
1101
+ return names;
1102
+ }
1103
+
1104
+ /// Android launcher icons: one PNG per density bucket under res/mipmap-*.
1105
+ const ANDROID_DENSITIES = [["mdpi", 48], ["hdpi", 72], ["xhdpi", 96], ["xxhdpi", 144], ["xxxhdpi", 192]];
1106
+
1107
+ function makeAndroidRes(src, resDir) {
1108
+ if (!haveTool("sips")) return false;
1109
+ let any = false;
1110
+ for (const [bucket, size] of ANDROID_DENSITIES) {
1111
+ const dir = join(resDir, `mipmap-${bucket}`);
1112
+ mkdirSync(dir, { recursive: true });
1113
+ if (resizePng(src, join(dir, "ic_launcher.png"), size)) any = true;
1114
+ }
1115
+ return any;
1116
+ }
1117
+
1118
+ /// A plain drag-to-Applications disk image from an existing .app.
1119
+ function makeDmg(appDir, outDir, name, version) {
1120
+ if (!haveTool("hdiutil")) {
1121
+ console.warn("janela: hdiutil not available — skipping .dmg");
1122
+ return null;
1123
+ }
1124
+ const stage = join(outDir, `.dmg-stage-${name}`);
1125
+ rmSync(stage, { recursive: true, force: true });
1126
+ mkdirSync(stage, { recursive: true });
1127
+ cpSync(appDir, join(stage, `${name}.app`), { recursive: true });
1128
+ spawnSync("ln", ["-s", "/Applications", join(stage, "Applications")], { stdio: "ignore" });
1129
+
1130
+ const dmg = join(outDir, `${name}-${version}.dmg`);
1131
+ rmSync(dmg, { force: true });
1132
+ const r = spawnSync("hdiutil", [
1133
+ "create", "-volname", name, "-srcfolder", stage,
1134
+ "-ov", "-format", "UDZO", "-quiet", dmg,
1135
+ ], { stdio: "inherit" });
1136
+ rmSync(stage, { recursive: true, force: true });
1137
+ if (r.status !== 0 || !existsSync(dmg)) {
1138
+ console.warn("janela: hdiutil failed — skipping .dmg");
1139
+ return null;
1140
+ }
1141
+ return dmg;
1142
+ }
1143
+
938
1144
  function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
939
1145
  const conf = loadConf(root);
940
1146
  const ios = target === "ios";
@@ -1064,10 +1270,39 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1064
1270
  console.log("janela: linked as a GUI-subsystem .exe (no console window)");
1065
1271
  }
1066
1272
 
1273
+ const icon = iconSource(root, conf);
1274
+
1275
+ if (process.platform === "win32" && icon) {
1276
+ // Embedding into the PE needs a resource compiler we cannot rely on, so
1277
+ // the .ico is written beside the .exe — installers and shortcuts take a
1278
+ // path, and this keeps the build toolchain-free.
1279
+ const ico = makeIco(icon, cacheDir, conf.name);
1280
+ if (ico) {
1281
+ cpSync(ico, join(outDir, `${conf.name}.ico`));
1282
+ console.log(`janela: wrote ${conf.name}.ico beside the .exe`);
1283
+ } else {
1284
+ console.warn("janela: could not generate a .ico (sips unavailable) — skipping the icon");
1285
+ }
1286
+ }
1287
+
1067
1288
  if (process.platform === "darwin") {
1068
1289
  const bundle = join(outDir, `${conf.name}.app`);
1290
+ rmSync(bundle, { recursive: true, force: true });
1069
1291
  mkdirSync(join(bundle, "Contents", "MacOS"), { recursive: true });
1070
1292
  cpSync(bin, join(bundle, "Contents", "MacOS", conf.name));
1293
+
1294
+ let iconKey = "";
1295
+ if (icon) {
1296
+ const icns = makeIcns(icon, cacheDir, conf.name);
1297
+ if (icns) {
1298
+ mkdirSync(join(bundle, "Contents", "Resources"), { recursive: true });
1299
+ cpSync(icns, join(bundle, "Contents", "Resources", `${conf.name}.icns`));
1300
+ iconKey = `\n <key>CFBundleIconFile</key><string>${conf.name}</string>`;
1301
+ } else {
1302
+ console.warn("janela: could not generate an .icns (iconutil/sips unavailable) — skipping the icon");
1303
+ }
1304
+ }
1305
+
1071
1306
  writeFileSync(
1072
1307
  join(bundle, "Contents", "Info.plist"),
1073
1308
  `<?xml version="1.0" encoding="UTF-8"?>
@@ -1081,13 +1316,21 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1081
1316
  <key>CFBundlePackageType</key><string>APPL</string>
1082
1317
  <key>CFBundleVersion</key><string>${conf.version ?? "0.1.0"}</string>
1083
1318
  <key>CFBundleShortVersionString</key><string>${conf.version ?? "0.1.0"}</string>
1084
- <key>NSHighResolutionCapable</key><true/>
1319
+ <key>NSHighResolutionCapable</key><true/>${iconKey}
1085
1320
  </dict>
1086
1321
  </plist>
1087
1322
  `,
1088
1323
  );
1089
1324
  spawnSync("codesign", ["--force", "--sign", "-", bundle]);
1090
1325
  console.log(`janela: built ${relative(root, bin)} and ${relative(root, bundle)}`);
1326
+
1327
+ // Opt-in: a .dmg is for shipping, not for `janela dev`.
1328
+ if (conf.bundle?.dmg) {
1329
+ const dmg = makeDmg(bundle, outDir, conf.name, conf.version ?? "0.1.0");
1330
+ if (dmg) console.log(`janela: built ${relative(root, dmg)} (${statSync(dmg).size} bytes)`);
1331
+ }
1332
+ } else if (process.platform !== "win32") {
1333
+ console.log(`janela: built ${relative(root, bin)}`);
1091
1334
  } else {
1092
1335
  console.log(`janela: built ${relative(root, bin)}`);
1093
1336
  }
@@ -1112,8 +1355,43 @@ function copyTemplate(from, to, name) {
1112
1355
  }
1113
1356
  }
1114
1357
 
1358
+ // A project name becomes an npm package name, a binary name, a bundle
1359
+ // identifier segment and a window title, so it is deliberately narrow:
1360
+ // start with a letter, then letters, digits, '-' or '_'. Underscores are
1361
+ // allowed because people type them and every downstream use accepts them —
1362
+ // Android application ids in particular *prefer* them, since a Java package
1363
+ // segment cannot contain a hyphen (see androidApplicationId).
1364
+ const NAME_RE = /^[a-z][a-z0-9_-]*$/;
1365
+
1366
+ // Best-effort repair of a rejected name, so the error can suggest something
1367
+ // that would have worked instead of only stating the rule.
1368
+ function suggestName(raw) {
1369
+ const s = String(raw)
1370
+ .toLowerCase()
1371
+ .replace(/[^a-z0-9_-]+/g, "-")
1372
+ .replace(/^[^a-z]+/, "")
1373
+ .replace(/-{2,}/g, "-")
1374
+ .replace(/[-_]+$/, "");
1375
+ return NAME_RE.test(s) ? s : "";
1376
+ }
1377
+
1115
1378
  function init(name, template) {
1116
- if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) fail("usage: janela init <name> [--template <t>] (lowercase, digits, dashes)");
1379
+ if (!name) {
1380
+ fail(
1381
+ "no project name given.\n" +
1382
+ " usage: janela init <name> [--template vanilla|vue|react|svelte|solid]",
1383
+ );
1384
+ }
1385
+ if (!NAME_RE.test(name)) {
1386
+ const hint = suggestName(name);
1387
+ fail(
1388
+ `'${name}' is not a usable project name.\n` +
1389
+ " A name must start with a lowercase letter, then contain only\n" +
1390
+ " lowercase letters, digits, '-' or '_'.\n" +
1391
+ (hint ? ` Try: janela init ${hint}\n` : "") +
1392
+ " Nothing was created.",
1393
+ );
1394
+ }
1117
1395
  if (!TEMPLATES.includes(template)) fail(`unknown template '${template}' (${TEMPLATES.join(", ")})`);
1118
1396
  const dir = resolve(process.cwd(), name);
1119
1397
  if (existsSync(dir)) fail(`${name}/ already exists`);
@@ -1245,15 +1523,50 @@ function targetOrFail() {
1245
1523
  return t;
1246
1524
  }
1247
1525
 
1526
+ // A mistyped flag used to be ignored in silence: `--targt ios` fell back to
1527
+ // the desktop default, built the wrong thing and exited 0. Anything a caller
1528
+ // did not spell exactly is now an error, because a build that quietly ignores
1529
+ // what it was asked for is indistinguishable from success.
1530
+ function assertKnownFlags(allowed) {
1531
+ const known = new Set(allowed);
1532
+ for (const a of argv.slice(1)) {
1533
+ if (!a.startsWith("--")) continue;
1534
+ const nm = a.slice(2).split("=")[0];
1535
+ if (!known.has(nm)) {
1536
+ const near = allowed.filter((k) => k.startsWith(nm.slice(0, 3)) || nm.startsWith(k.slice(0, 3)));
1537
+ fail(
1538
+ `unknown option '--${nm}' for 'janela ${cmd}'.\n` +
1539
+ ` Known options: ${allowed.map((k) => `--${k}`).join(", ") || "(none)"}` +
1540
+ (near.length ? `\n Did you mean --${near[0]}?` : ""),
1541
+ );
1542
+ }
1543
+ }
1544
+ }
1545
+
1546
+ // Extra positionals were silently dropped, so `janela init a b` created 'a'
1547
+ // and said nothing about 'b'.
1548
+ function assertPositionals(max) {
1549
+ const p = positionals();
1550
+ if (p.length > max) {
1551
+ fail(`unexpected extra argument '${p[max]}' for 'janela ${cmd}'.\n Nothing was created.`);
1552
+ }
1553
+ }
1554
+
1248
1555
  switch (cmd) {
1249
1556
  case "init":
1557
+ assertKnownFlags(["template"]);
1558
+ assertPositionals(1);
1250
1559
  init(positionals()[0], flag("template", "vanilla"));
1251
1560
  break;
1252
1561
  case "build":
1562
+ assertKnownFlags(["target"]);
1563
+ assertPositionals(0);
1253
1564
  build(process.cwd(), { target: targetOrFail() });
1254
1565
  break;
1255
1566
  case "dev":
1256
1567
  {
1568
+ assertKnownFlags(["target"]);
1569
+ assertPositionals(0);
1257
1570
  const t = targetOrFail();
1258
1571
  if (t === "ios") await devIos(process.cwd());
1259
1572
  else if (t === "android") await devAndroid(process.cwd());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Desktop, iOS and Android apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {
package/shim/ios/app.cc CHANGED
@@ -17,13 +17,17 @@
17
17
  #include "webview.h"
18
18
 
19
19
  #include <dispatch/dispatch.h>
20
+ #include <os/log.h>
21
+ #include <unistd.h>
20
22
 
23
+ #include <cstdio>
21
24
  #include <cstdlib>
22
25
  #include <cstring>
23
26
  #include <fstream>
24
27
  #include <map>
25
28
  #include <sstream>
26
29
  #include <string>
30
+ #include <vector>
27
31
 
28
32
  // ---- the scriptc library's C ABI (see the generated profile) ---------------
29
33
  extern "C" {
@@ -44,6 +48,113 @@ namespace {
44
48
 
45
49
  webview::webview *g_webview = nullptr;
46
50
 
51
+ // ---- logging ---------------------------------------------------------------
52
+ //
53
+ // scriptc's console.log writes to stdout. On a desktop that is where a
54
+ // developer is looking; on iOS nobody is, because the unified log is what
55
+ // `log show`, `log stream` and Console.app read, and it is the only channel
56
+ // `xcrun simctl` can surface. Screenshotting the page to find out what the
57
+ // host printed is not a debugging story.
58
+ //
59
+ // So the shell tees the library's stdout and stderr into os_log, line by line,
60
+ // under a stable subsystem and category so they can be filtered:
61
+ //
62
+ // xcrun simctl spawn booted log stream --predicate \
63
+ // 'subsystem == "dev.janela"'
64
+ //
65
+ // The original descriptors are kept and still written, so a run attached to a
66
+ // terminal prints exactly as before — the tee adds a destination, it does not
67
+ // move one.
68
+
69
+ os_log_t janela_log() {
70
+ static os_log_t log = os_log_create("dev.janela", "host");
71
+ return log;
72
+ }
73
+
74
+ /// Replace `fd` with a pipe, forwarding every line to os_log and on to the
75
+ /// original descriptor. One reader queue per fd; the shell may create threads
76
+ /// even though the library may not.
77
+ void tee_fd_to_oslog(int fd, os_log_type_t type, const char *label) {
78
+ int original = dup(fd);
79
+ if (original < 0) {
80
+ return;
81
+ }
82
+ int fds[2];
83
+ if (pipe(fds) != 0) {
84
+ close(original);
85
+ return;
86
+ }
87
+ if (dup2(fds[1], fd) < 0) {
88
+ close(fds[0]);
89
+ close(fds[1]);
90
+ close(original);
91
+ return;
92
+ }
93
+ close(fds[1]);
94
+
95
+ int read_fd = fds[0];
96
+ // A pipe is not a tty, so stdio would switch to full buffering and hold the
97
+ // library's output until the buffer filled or the process exited. Neither is
98
+ // acceptable for a log, so pin line buffering back on.
99
+ if (fd == STDOUT_FILENO) {
100
+ setvbuf(stdout, nullptr, _IOLBF, 0);
101
+ } else if (fd == STDERR_FILENO) {
102
+ setvbuf(stderr, nullptr, _IOLBF, 0);
103
+ }
104
+
105
+ dispatch_queue_t q = dispatch_queue_create("dev.janela.log", DISPATCH_QUEUE_SERIAL);
106
+ dispatch_async(q, ^{
107
+ std::string pending;
108
+ std::vector<char> buf(4096);
109
+ for (;;) {
110
+ ssize_t n = read(read_fd, buf.data(), buf.size());
111
+ if (n <= 0) {
112
+ break; // writer closed, or an unrecoverable error
113
+ }
114
+ // Pass the bytes through untouched first: stdout must behave as before.
115
+ ssize_t off = 0;
116
+ while (off < n) {
117
+ ssize_t w = write(original, buf.data() + off, (size_t)(n - off));
118
+ if (w <= 0) {
119
+ break;
120
+ }
121
+ off += w;
122
+ }
123
+ // Then split into lines for the unified log, which is line-oriented.
124
+ // %{public}s is required: os_log redacts a plain %s as <private>.
125
+ pending.append(buf.data(), (size_t)n);
126
+ size_t nl;
127
+ while ((nl = pending.find('\n')) != std::string::npos) {
128
+ std::string line = pending.substr(0, nl);
129
+ pending.erase(0, nl + 1);
130
+ if (!line.empty() && line.back() == '\r') {
131
+ line.pop_back();
132
+ }
133
+ if (!line.empty()) {
134
+ os_log_with_type(janela_log(), type, "%{public}s", line.c_str());
135
+ }
136
+ }
137
+ // A very long line with no newline would otherwise grow without bound.
138
+ if (pending.size() > 64 * 1024) {
139
+ os_log_with_type(janela_log(), type, "%{public}s", pending.c_str());
140
+ pending.clear();
141
+ }
142
+ }
143
+ if (!pending.empty()) {
144
+ os_log_with_type(janela_log(), type, "%{public}s", pending.c_str());
145
+ }
146
+ });
147
+ os_log_with_type(janela_log(), OS_LOG_TYPE_INFO,
148
+ "janela: %{public}s is mirrored to the unified log", label);
149
+ }
150
+
151
+ /// Mirror the library's output into the unified log. Called before jl_init(),
152
+ /// so anything setup() prints is already captured.
153
+ void start_logging() {
154
+ tee_fd_to_oslog(STDOUT_FILENO, OS_LOG_TYPE_DEFAULT, "stdout");
155
+ tee_fd_to_oslog(STDERR_FILENO, OS_LOG_TYPE_ERROR, "stderr");
156
+ }
157
+
47
158
  /// Copy a library-owned result out of its arena and release it. Results live
48
159
  /// until the next jl_reset(), so nothing may hold the pointer past this call.
49
160
  std::string take_result(char *out, size_t out_len) {
@@ -301,6 +412,9 @@ void on_invoke(const std::string &binding_id, const std::string &req, void *) {
301
412
  } // namespace
302
413
 
303
414
  int main() {
415
+ // Before anything else, so setup()'s own output is captured too.
416
+ start_logging();
417
+
304
418
  // Registration is a pure store and is legal before init; the panic sink and
305
419
  // every channel must be in place before any TypeScript runs, since setup()
306
420
  // executes during jl_init().