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 +50 -18
- package/bin/janela.mjs +341 -15
- package/package.json +1 -1
- package/runtime/ios.ts +82 -8
- package/shim/android/app.cc +380 -0
- package/shim/android/java/dev/janela/host/JanelaActivity.java +144 -0
- package/shim/ios/app.cc +388 -0
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
|
|
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)
|
|
9
|
-
|
|
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
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 **
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
findings behind it are in
|
|
587
|
-
[docs/findings.md](../../docs/findings.md)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
|
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());
|