janela 0.11.0 → 0.13.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
 
@@ -18,7 +33,7 @@ janela dev # build + run with logs in the terminal
18
33
  janela build # .janela/out/my-app (+ my-app.app on macOS)
19
34
  ```
20
35
 
21
- Or start from a frontend framework — any Vite-based one:
36
+ Or start from a frontend framework:
22
37
 
23
38
  ```bash
24
39
  janela init my-app --template vue # or react | svelte | solid | vanilla
@@ -28,11 +43,23 @@ janela dev # Vite dev server + HMR, in a native windo
28
43
 
29
44
  `vanilla` is the default and needs no frontend toolchain at all. With a
30
45
  framework, `janela dev` runs your Vite dev server and points the window at it,
31
- and `janela build` flattens the production bundle into the binary — see
32
- [docs/frontend.md](../../docs/frontend.md).
33
-
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
46
+ and `janela build` flattens the production bundle into the binary.
47
+
48
+ All five templates are built and run on desktop, the iOS simulator and an
49
+ Android emulator the matrix and sizes are in
50
+ [docs/frontend.md](../../docs/frontend.md). Your own Vite project works too, as
51
+ long as it produces a **single-page `dist`**: multi-entry builds, SSR/SSG
52
+ (Astro, Nuxt) and frameworks with their own non-Vite build are out of scope,
53
+ because the output is flattened into one HTML document.
54
+
55
+ Packaging for distribution — icons, a macOS `.dmg`, Android release signing, and
56
+ what requires an Apple or Google account — is in
57
+ [docs/distribution.md](../../docs/distribution.md).
58
+
59
+ Requirements: Node 24+ and a C++ toolchain for the platform you are building —
60
+ Xcode CLT on macOS; `g++` + `libwebkit2gtk-4.1-dev` on Linux; an llvm-mingw
61
+ clang on Windows (see [Windows](#windows) below). iOS additionally needs Xcode
62
+ and `zig`; Android needs a JDK, the Android SDK, the NDK and `zig`. A worked
36
63
  example lives in [`examples/demo`](examples/demo) — commands, events, and a
37
64
  file reader.
38
65
 
@@ -552,7 +579,8 @@ janela build --target ios # -> .janela/out-ios/<name>.app (simulator)
552
579
  janela dev --target ios # build, boot a simulator, install, launch
553
580
  ```
554
581
 
555
- It is **not part of a release yet** and is simulator-only. Commands, the typed
582
+ It is **simulator-only** so far device builds and code signing are not
583
+ wired up yet. Commands, the typed
556
584
  contract, events, Vite frontends, async commands (`commandAsync`, `defer`,
557
585
  `sleep`) and file I/O all work the same as on desktop — the shell owns the
558
586
  clock and the file queue on both. File dialogs are not on iOS yet and report
@@ -581,13 +609,17 @@ whose callbacks native code cannot receive on its own.
581
609
 
582
610
  ## Status
583
611
 
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.
612
+ Young and pre-1.0. Desktop (macOS arm64, Linux/WebKitGTK, Windows/WebView2) is
613
+ the most exercised path; iOS and Android are newer, and iOS is simulator-only.
614
+ The design notes and scriptc findings behind it are in
615
+ [docs/findings.md](../../docs/findings.md), with per-platform notes in
616
+ [docs/ios.md](../../docs/ios.md) and [docs/android.md](../../docs/android.md).
617
+
618
+ Not yet: native dialogs and window control on mobile; device builds and code
619
+ signing; icons, installers and notarization; async commands that run in
620
+ parallel (host code is single-threaded, so `commandAsync` interleaves and a
621
+ CPU-bound handler still needs slicing); an async HTTP client; tray icons and
622
+ menus; multi-window; directory picking on Windows; and `app.center()`.
591
623
 
592
624
  ## Releasing
593
625
 
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";
@@ -351,6 +351,15 @@ function libraryProfile() {
351
351
  params: ["f64", "bool", "string"],
352
352
  returns: "void",
353
353
  },
354
+ // Deliberately its own export rather than reusing onFsDone, whose
355
+ // signature would fit: a dialog result arriving through the file-I/O
356
+ // path would read as a bug for as long as the code lived.
357
+ {
358
+ export: "onDialogDone",
359
+ symbol: `${IOS_PREFIX}on_dialog_done`,
360
+ params: ["f64", "bool", "string"],
361
+ returns: "void",
362
+ },
354
363
  ],
355
364
  // TS -> shell. A channel handler must never re-enter the library (see
356
365
  // upstream #263: violations silently appear to work), so every one of
@@ -362,12 +371,20 @@ function libraryProfile() {
362
371
  { name: "hostSettle", params: ["f64", "string"], returns: "void" },
363
372
  { name: "hostReadFile", params: ["f64", "string"], returns: "void" },
364
373
  { name: "hostWriteFile", params: ["f64", "string", "string"], returns: "void" },
374
+ { name: "hostOpenDialog", params: ["f64", "string"], returns: "void" },
365
375
  ],
366
376
  };
367
377
  }
368
378
 
369
- function iosPlist(conf) {
379
+ function iosPlist(conf, iconFiles = []) {
370
380
  const ios = iosConf(conf);
381
+ // The asset-catalogue route needs actool; the CFBundleIconFiles list is the
382
+ // older mechanism and keeps this hand-assembled bundle toolchain-free.
383
+ const icons = iconFiles.length
384
+ ? `\n <key>CFBundleIconFiles</key><array>${iconFiles
385
+ .map((f) => `<string>${f}</string>`)
386
+ .join("")}</array>`
387
+ : "";
371
388
  return `<?xml version="1.0" encoding="UTF-8"?>
372
389
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
373
390
  <plist version="1.0">
@@ -382,7 +399,7 @@ function iosPlist(conf) {
382
399
  <key>LSRequiresIPhoneOS</key><true/>
383
400
  <key>UILaunchScreen</key><dict/>
384
401
  <key>MinimumOSVersion</key><string>${ios.minimumVersion}</string>
385
- <key>CFBundleSupportedPlatforms</key><array><string>iPhoneSimulator</string></array>
402
+ <key>CFBundleSupportedPlatforms</key><array><string>iPhoneSimulator</string></array>${icons}
386
403
  </dict>
387
404
  </plist>
388
405
  `;
@@ -433,7 +450,16 @@ function buildIos(root, conf, buildDir, outDir) {
433
450
  "-o", join(bundle, conf.name),
434
451
  ]);
435
452
  run(["strip", join(bundle, conf.name)]);
436
- writeFileSync(join(bundle, "Info.plist"), iosPlist(conf));
453
+
454
+ const icon = iconSource(root, conf);
455
+ let iconFiles = [];
456
+ if (icon) {
457
+ iconFiles = makeIosIcons(icon, bundle);
458
+ if (!iconFiles.length) {
459
+ console.warn("janela: could not generate iOS icons (sips unavailable) — building without one");
460
+ }
461
+ }
462
+ writeFileSync(join(bundle, "Info.plist"), iosPlist(conf, iconFiles));
437
463
 
438
464
  console.log(
439
465
  `janela: built ${relative(root, bundle)} ` +
@@ -547,15 +573,16 @@ function javaHome() {
547
573
  fail("Android builds need a JDK. Install one (`brew install openjdk`) and set JAVA_HOME");
548
574
  }
549
575
 
550
- function androidManifest(conf) {
576
+ function androidManifest(conf, { icon = false } = {}) {
551
577
  const a = androidConf(conf);
578
+ const iconAttr = icon ? ` android:icon="@mipmap/ic_launcher"` : "";
552
579
  return `<?xml version="1.0" encoding="utf-8"?>
553
580
  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
554
581
  package="${a.applicationId}"
555
582
  android:versionCode="1"
556
583
  android:versionName="${conf.version ?? "0.1.0"}">
557
584
  <uses-permission android:name="android.permission.INTERNET"/>
558
- <application android:label="${a.label}" android:hasCode="true">
585
+ <application android:label="${a.label}"${iconAttr} android:hasCode="true">
559
586
  <activity android:name="dev.janela.host.JanelaActivity" android:exported="true">
560
587
  <intent-filter>
561
588
  <action android:name="android.intent.action.MAIN"/>
@@ -652,7 +679,22 @@ function buildAndroid(root, conf, buildDir, outDir) {
652
679
  collect(classes);
653
680
  run([join(sdk.bt, "d8"), "--min-api", a.minSdk, "--output", stage, ...classFiles]);
654
681
 
655
- writeFileSync(join(buildDir, "AndroidManifest.xml"), androidManifest(conf));
682
+ // Launcher icons, if the project has one. aapt2 needs resources compiled to
683
+ // .flat before they can be linked, so this is a two-step detour.
684
+ const icon = iconSource(root, conf);
685
+ let resZip = null;
686
+ if (icon) {
687
+ const resDir = join(buildDir, "res");
688
+ rmSync(resDir, { recursive: true, force: true });
689
+ if (makeAndroidRes(icon, resDir)) {
690
+ resZip = join(buildDir, "res.zip");
691
+ run([join(sdk.bt, "aapt2"), "compile", "--dir", resDir, "-o", resZip]);
692
+ } else {
693
+ console.warn("janela: could not generate launcher icons (sips unavailable) — building without one");
694
+ }
695
+ }
696
+
697
+ writeFileSync(join(buildDir, "AndroidManifest.xml"), androidManifest(conf, { icon: Boolean(resZip) }));
656
698
 
657
699
  console.log("janela: packaging the APK");
658
700
  const unsigned = join(buildDir, "unsigned.apk");
@@ -661,6 +703,7 @@ function buildAndroid(root, conf, buildDir, outDir) {
661
703
  "--manifest", join(buildDir, "AndroidManifest.xml"),
662
704
  "--min-sdk-version", a.minSdk,
663
705
  "--target-sdk-version", String(ANDROID_TARGET_SDK),
706
+ ...(resZip ? [resZip] : []),
664
707
  ]);
665
708
  // aapt2 emits the manifest and resources; the code and the shared library
666
709
  // are added to the same zip afterwards.
@@ -669,12 +712,40 @@ function buildAndroid(root, conf, buildDir, outDir) {
669
712
  const aligned = join(buildDir, "aligned.apk");
670
713
  run([join(sdk.bt, "zipalign"), "-f", "4", unsigned, aligned]);
671
714
  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
- ]);
715
+ // A release keystore is the user's to own: janela never creates one and
716
+ // never reads a password from the config file. Point `bundle.androidKeystore`
717
+ // at a .jks and supply the passwords through the environment; absent that,
718
+ // the APK is signed with a throwaway debug key that Play Store will reject.
719
+ const ksConf = conf.bundle?.androidKeystore;
720
+ if (ksConf) {
721
+ const ksPath = resolve(root, ksConf.path ?? fail("bundle.androidKeystore needs a 'path'"));
722
+ if (!existsSync(ksPath)) fail(`bundle.androidKeystore.path does not exist: ${ksPath}`);
723
+ const storeEnv = ksConf.storePasswordEnv ?? "JANELA_ANDROID_STORE_PASSWORD";
724
+ const keyEnv = ksConf.keyPasswordEnv ?? storeEnv;
725
+ const storePass = process.env[storeEnv];
726
+ if (!storePass) {
727
+ fail(
728
+ `bundle.androidKeystore is configured but $${storeEnv} is not set.\n` +
729
+ " Export the keystore password in the environment; janela will not read it from a file.",
730
+ );
731
+ }
732
+ const keyPass = process.env[keyEnv] ?? storePass;
733
+ run([
734
+ join(sdk.bt, "apksigner"), "sign",
735
+ "--ks", ksPath,
736
+ ...(ksConf.alias ? ["--ks-key-alias", ksConf.alias] : []),
737
+ "--ks-pass", `pass:${storePass}`, "--key-pass", `pass:${keyPass}`,
738
+ "--out", apk, aligned,
739
+ ]);
740
+ console.log(`janela: signed with ${relative(root, ksPath)}`);
741
+ } else {
742
+ run([
743
+ join(sdk.bt, "apksigner"), "sign",
744
+ "--ks", debugKeystore(cacheDir, jdk),
745
+ "--ks-pass", "pass:android", "--key-pass", "pass:android",
746
+ "--out", apk, aligned,
747
+ ]);
748
+ }
678
749
 
679
750
  console.log(
680
751
  `janela: built ${relative(root, apk)} ` +
@@ -935,6 +1006,151 @@ function makeGuiSubsystem(exePath) {
935
1006
 
936
1007
  // `devUrl` points the window at a running vite server instead of inlining the
937
1008
  // frontend; `gui` asks for a GUI-subsystem .exe on Windows (build, not dev).
1009
+ // ---- icons and packaging ---------------------------------------------------
1010
+ //
1011
+ // One square source image becomes whatever each platform wants. Everything
1012
+ // here is optional: a project with no icon configured and no icon.png builds
1013
+ // exactly as it did before, and a platform whose converter is unavailable is
1014
+ // skipped with a warning rather than failing the build.
1015
+ //
1016
+ // `sips` and `iconutil` ship with macOS, so icon generation currently requires
1017
+ // building on a Mac. That is already true of .app/.dmg/iOS output.
1018
+
1019
+ /// The configured icon, or `icon.png` beside janela.conf.json, or null.
1020
+ function iconSource(root, conf) {
1021
+ const named = conf.bundle?.icon ?? conf.icon;
1022
+ if (named) {
1023
+ const p = resolve(root, named);
1024
+ if (!existsSync(p)) fail(`bundle.icon '${named}' does not exist (resolved to ${p})`);
1025
+ return p;
1026
+ }
1027
+ const fallback = join(root, "icon.png");
1028
+ return existsSync(fallback) ? fallback : null;
1029
+ }
1030
+
1031
+ function haveTool(name) {
1032
+ return spawnSync("command", ["-v", name], { shell: true, stdio: "ignore" }).status === 0;
1033
+ }
1034
+
1035
+ /// Square PNG at `size`, written to `dst`. Returns false if sips is missing.
1036
+ function resizePng(src, dst, size) {
1037
+ if (!haveTool("sips")) return false;
1038
+ const r = spawnSync("sips", ["-z", String(size), String(size), src, "--out", dst], { stdio: "ignore" });
1039
+ return r.status === 0;
1040
+ }
1041
+
1042
+ const ICNS_SIZES = [16, 32, 64, 128, 256, 512, 1024];
1043
+
1044
+ /// macOS .icns via the iconset convention iconutil expects.
1045
+ function makeIcns(src, cacheDir, name) {
1046
+ if (!haveTool("iconutil") || !haveTool("sips")) return null;
1047
+ const iconset = join(cacheDir, `${name}.iconset`);
1048
+ rmSync(iconset, { recursive: true, force: true });
1049
+ mkdirSync(iconset, { recursive: true });
1050
+ // iconutil wants both @1x and @2x names; a 32px @2x is the 64px render.
1051
+ for (const s of ICNS_SIZES) {
1052
+ if (s <= 512) resizePng(src, join(iconset, `icon_${s}x${s}.png`), s);
1053
+ if (s >= 32) resizePng(src, join(iconset, `icon_${s / 2}x${s / 2}@2x.png`), s);
1054
+ }
1055
+ const icns = join(cacheDir, `${name}.icns`);
1056
+ const r = spawnSync("iconutil", ["-c", "icns", iconset, "-o", icns], { stdio: "ignore" });
1057
+ return r.status === 0 && existsSync(icns) ? icns : null;
1058
+ }
1059
+
1060
+ const ICO_SIZES = [16, 32, 48, 64, 128, 256];
1061
+
1062
+ /// Windows .ico. The format allows PNG payloads (Vista+), so this needs no
1063
+ /// image library: a 6-byte header, one 16-byte directory entry per image,
1064
+ /// then the PNG bytes.
1065
+ function makeIco(src, cacheDir, name) {
1066
+ if (!haveTool("sips")) return null;
1067
+ const pngs = [];
1068
+ for (const s of ICO_SIZES) {
1069
+ const p = join(cacheDir, `ico-${s}.png`);
1070
+ if (resizePng(src, p, s)) pngs.push({ size: s, data: readFileSync(p) });
1071
+ }
1072
+ if (!pngs.length) return null;
1073
+
1074
+ const header = Buffer.alloc(6);
1075
+ header.writeUInt16LE(0, 0); // reserved
1076
+ header.writeUInt16LE(1, 2); // type: icon
1077
+ header.writeUInt16LE(pngs.length, 4);
1078
+
1079
+ let offset = 6 + pngs.length * 16;
1080
+ const entries = [];
1081
+ for (const { size, data } of pngs) {
1082
+ const e = Buffer.alloc(16);
1083
+ e.writeUInt8(size >= 256 ? 0 : size, 0); // 0 means 256
1084
+ e.writeUInt8(size >= 256 ? 0 : size, 1);
1085
+ e.writeUInt8(0, 2); // palette
1086
+ e.writeUInt8(0, 3); // reserved
1087
+ e.writeUInt16LE(1, 4); // colour planes
1088
+ e.writeUInt16LE(32, 6); // bits per pixel
1089
+ e.writeUInt32LE(data.length, 8);
1090
+ e.writeUInt32LE(offset, 12);
1091
+ entries.push(e);
1092
+ offset += data.length;
1093
+ }
1094
+ const ico = join(cacheDir, `${name}.ico`);
1095
+ writeFileSync(ico, Buffer.concat([header, ...entries, ...pngs.map((p) => p.data)]));
1096
+ return ico;
1097
+ }
1098
+
1099
+ /// iOS icons. The modern route is a compiled asset catalogue; the older
1100
+ /// CFBundleIconFiles list still works and needs no actool, which keeps the
1101
+ /// hand-rolled bundle self-contained.
1102
+ const IOS_ICON_SIZES = [40, 58, 60, 80, 87, 120, 180, 1024];
1103
+
1104
+ function makeIosIcons(src, bundleDir) {
1105
+ if (!haveTool("sips")) return [];
1106
+ const names = [];
1107
+ for (const s of IOS_ICON_SIZES) {
1108
+ const base = `AppIcon${s}.png`;
1109
+ if (resizePng(src, join(bundleDir, base), s)) names.push(base);
1110
+ }
1111
+ return names;
1112
+ }
1113
+
1114
+ /// Android launcher icons: one PNG per density bucket under res/mipmap-*.
1115
+ const ANDROID_DENSITIES = [["mdpi", 48], ["hdpi", 72], ["xhdpi", 96], ["xxhdpi", 144], ["xxxhdpi", 192]];
1116
+
1117
+ function makeAndroidRes(src, resDir) {
1118
+ if (!haveTool("sips")) return false;
1119
+ let any = false;
1120
+ for (const [bucket, size] of ANDROID_DENSITIES) {
1121
+ const dir = join(resDir, `mipmap-${bucket}`);
1122
+ mkdirSync(dir, { recursive: true });
1123
+ if (resizePng(src, join(dir, "ic_launcher.png"), size)) any = true;
1124
+ }
1125
+ return any;
1126
+ }
1127
+
1128
+ /// A plain drag-to-Applications disk image from an existing .app.
1129
+ function makeDmg(appDir, outDir, name, version) {
1130
+ if (!haveTool("hdiutil")) {
1131
+ console.warn("janela: hdiutil not available — skipping .dmg");
1132
+ return null;
1133
+ }
1134
+ const stage = join(outDir, `.dmg-stage-${name}`);
1135
+ rmSync(stage, { recursive: true, force: true });
1136
+ mkdirSync(stage, { recursive: true });
1137
+ cpSync(appDir, join(stage, `${name}.app`), { recursive: true });
1138
+ spawnSync("ln", ["-s", "/Applications", join(stage, "Applications")], { stdio: "ignore" });
1139
+
1140
+ const dmg = join(outDir, `${name}-${version}.dmg`);
1141
+ rmSync(dmg, { force: true });
1142
+ const r = spawnSync("hdiutil", [
1143
+ "create", "-volname", name, "-srcfolder", stage,
1144
+ "-ov", "-format", "UDZO", "-quiet", dmg,
1145
+ ], { stdio: "inherit" });
1146
+ rmSync(stage, { recursive: true, force: true });
1147
+ if (r.status !== 0 || !existsSync(dmg)) {
1148
+ console.warn("janela: hdiutil failed — skipping .dmg");
1149
+ return null;
1150
+ }
1151
+ return dmg;
1152
+ }
1153
+
938
1154
  function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
939
1155
  const conf = loadConf(root);
940
1156
  const ios = target === "ios";
@@ -1013,6 +1229,9 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1013
1229
  `/** A file job the shell owns has finished (main queue). */\n` +
1014
1230
  `export function onFsDone(id: number, ok: boolean, payload: string): void {\n` +
1015
1231
  ` app.onFsDone(id, ok, payload);\n` +
1232
+ `}\n` +
1233
+ `export function onDialogDone(id: number, ok: boolean, payload: string): void {\n` +
1234
+ ` app.onDialogDone(id, ok, payload);\n` +
1016
1235
  `}\n`
1017
1236
  : `const app = createApp<CmdsOf<typeof setup>, EvtsOf<typeof setup>>(WINDOW);\n` +
1018
1237
  `setup(app);\n` +
@@ -1064,10 +1283,39 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1064
1283
  console.log("janela: linked as a GUI-subsystem .exe (no console window)");
1065
1284
  }
1066
1285
 
1286
+ const icon = iconSource(root, conf);
1287
+
1288
+ if (process.platform === "win32" && icon) {
1289
+ // Embedding into the PE needs a resource compiler we cannot rely on, so
1290
+ // the .ico is written beside the .exe — installers and shortcuts take a
1291
+ // path, and this keeps the build toolchain-free.
1292
+ const ico = makeIco(icon, cacheDir, conf.name);
1293
+ if (ico) {
1294
+ cpSync(ico, join(outDir, `${conf.name}.ico`));
1295
+ console.log(`janela: wrote ${conf.name}.ico beside the .exe`);
1296
+ } else {
1297
+ console.warn("janela: could not generate a .ico (sips unavailable) — skipping the icon");
1298
+ }
1299
+ }
1300
+
1067
1301
  if (process.platform === "darwin") {
1068
1302
  const bundle = join(outDir, `${conf.name}.app`);
1303
+ rmSync(bundle, { recursive: true, force: true });
1069
1304
  mkdirSync(join(bundle, "Contents", "MacOS"), { recursive: true });
1070
1305
  cpSync(bin, join(bundle, "Contents", "MacOS", conf.name));
1306
+
1307
+ let iconKey = "";
1308
+ if (icon) {
1309
+ const icns = makeIcns(icon, cacheDir, conf.name);
1310
+ if (icns) {
1311
+ mkdirSync(join(bundle, "Contents", "Resources"), { recursive: true });
1312
+ cpSync(icns, join(bundle, "Contents", "Resources", `${conf.name}.icns`));
1313
+ iconKey = `\n <key>CFBundleIconFile</key><string>${conf.name}</string>`;
1314
+ } else {
1315
+ console.warn("janela: could not generate an .icns (iconutil/sips unavailable) — skipping the icon");
1316
+ }
1317
+ }
1318
+
1071
1319
  writeFileSync(
1072
1320
  join(bundle, "Contents", "Info.plist"),
1073
1321
  `<?xml version="1.0" encoding="UTF-8"?>
@@ -1081,13 +1329,21 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1081
1329
  <key>CFBundlePackageType</key><string>APPL</string>
1082
1330
  <key>CFBundleVersion</key><string>${conf.version ?? "0.1.0"}</string>
1083
1331
  <key>CFBundleShortVersionString</key><string>${conf.version ?? "0.1.0"}</string>
1084
- <key>NSHighResolutionCapable</key><true/>
1332
+ <key>NSHighResolutionCapable</key><true/>${iconKey}
1085
1333
  </dict>
1086
1334
  </plist>
1087
1335
  `,
1088
1336
  );
1089
1337
  spawnSync("codesign", ["--force", "--sign", "-", bundle]);
1090
1338
  console.log(`janela: built ${relative(root, bin)} and ${relative(root, bundle)}`);
1339
+
1340
+ // Opt-in: a .dmg is for shipping, not for `janela dev`.
1341
+ if (conf.bundle?.dmg) {
1342
+ const dmg = makeDmg(bundle, outDir, conf.name, conf.version ?? "0.1.0");
1343
+ if (dmg) console.log(`janela: built ${relative(root, dmg)} (${statSync(dmg).size} bytes)`);
1344
+ }
1345
+ } else if (process.platform !== "win32") {
1346
+ console.log(`janela: built ${relative(root, bin)}`);
1091
1347
  } else {
1092
1348
  console.log(`janela: built ${relative(root, bin)}`);
1093
1349
  }
@@ -1112,8 +1368,43 @@ function copyTemplate(from, to, name) {
1112
1368
  }
1113
1369
  }
1114
1370
 
1371
+ // A project name becomes an npm package name, a binary name, a bundle
1372
+ // identifier segment and a window title, so it is deliberately narrow:
1373
+ // start with a letter, then letters, digits, '-' or '_'. Underscores are
1374
+ // allowed because people type them and every downstream use accepts them —
1375
+ // Android application ids in particular *prefer* them, since a Java package
1376
+ // segment cannot contain a hyphen (see androidApplicationId).
1377
+ const NAME_RE = /^[a-z][a-z0-9_-]*$/;
1378
+
1379
+ // Best-effort repair of a rejected name, so the error can suggest something
1380
+ // that would have worked instead of only stating the rule.
1381
+ function suggestName(raw) {
1382
+ const s = String(raw)
1383
+ .toLowerCase()
1384
+ .replace(/[^a-z0-9_-]+/g, "-")
1385
+ .replace(/^[^a-z]+/, "")
1386
+ .replace(/-{2,}/g, "-")
1387
+ .replace(/[-_]+$/, "");
1388
+ return NAME_RE.test(s) ? s : "";
1389
+ }
1390
+
1115
1391
  function init(name, template) {
1116
- if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) fail("usage: janela init <name> [--template <t>] (lowercase, digits, dashes)");
1392
+ if (!name) {
1393
+ fail(
1394
+ "no project name given.\n" +
1395
+ " usage: janela init <name> [--template vanilla|vue|react|svelte|solid]",
1396
+ );
1397
+ }
1398
+ if (!NAME_RE.test(name)) {
1399
+ const hint = suggestName(name);
1400
+ fail(
1401
+ `'${name}' is not a usable project name.\n` +
1402
+ " A name must start with a lowercase letter, then contain only\n" +
1403
+ " lowercase letters, digits, '-' or '_'.\n" +
1404
+ (hint ? ` Try: janela init ${hint}\n` : "") +
1405
+ " Nothing was created.",
1406
+ );
1407
+ }
1117
1408
  if (!TEMPLATES.includes(template)) fail(`unknown template '${template}' (${TEMPLATES.join(", ")})`);
1118
1409
  const dir = resolve(process.cwd(), name);
1119
1410
  if (existsSync(dir)) fail(`${name}/ already exists`);
@@ -1245,15 +1536,50 @@ function targetOrFail() {
1245
1536
  return t;
1246
1537
  }
1247
1538
 
1539
+ // A mistyped flag used to be ignored in silence: `--targt ios` fell back to
1540
+ // the desktop default, built the wrong thing and exited 0. Anything a caller
1541
+ // did not spell exactly is now an error, because a build that quietly ignores
1542
+ // what it was asked for is indistinguishable from success.
1543
+ function assertKnownFlags(allowed) {
1544
+ const known = new Set(allowed);
1545
+ for (const a of argv.slice(1)) {
1546
+ if (!a.startsWith("--")) continue;
1547
+ const nm = a.slice(2).split("=")[0];
1548
+ if (!known.has(nm)) {
1549
+ const near = allowed.filter((k) => k.startsWith(nm.slice(0, 3)) || nm.startsWith(k.slice(0, 3)));
1550
+ fail(
1551
+ `unknown option '--${nm}' for 'janela ${cmd}'.\n` +
1552
+ ` Known options: ${allowed.map((k) => `--${k}`).join(", ") || "(none)"}` +
1553
+ (near.length ? `\n Did you mean --${near[0]}?` : ""),
1554
+ );
1555
+ }
1556
+ }
1557
+ }
1558
+
1559
+ // Extra positionals were silently dropped, so `janela init a b` created 'a'
1560
+ // and said nothing about 'b'.
1561
+ function assertPositionals(max) {
1562
+ const p = positionals();
1563
+ if (p.length > max) {
1564
+ fail(`unexpected extra argument '${p[max]}' for 'janela ${cmd}'.\n Nothing was created.`);
1565
+ }
1566
+ }
1567
+
1248
1568
  switch (cmd) {
1249
1569
  case "init":
1570
+ assertKnownFlags(["template"]);
1571
+ assertPositionals(1);
1250
1572
  init(positionals()[0], flag("template", "vanilla"));
1251
1573
  break;
1252
1574
  case "build":
1575
+ assertKnownFlags(["target"]);
1576
+ assertPositionals(0);
1253
1577
  build(process.cwd(), { target: targetOrFail() });
1254
1578
  break;
1255
1579
  case "dev":
1256
1580
  {
1581
+ assertKnownFlags(["target"]);
1582
+ assertPositionals(0);
1257
1583
  const t = targetOrFail();
1258
1584
  if (t === "ios") await devIos(process.cwd());
1259
1585
  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.13.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": {