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 +36 -14
- package/bin/janela.mjs +328 -15
- package/package.json +1 -1
- package/shim/ios/app.cc +114 -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
|
|
|
@@ -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
|
|
35
|
-
`libwebkit2gtk-4.1-dev` on Linux;
|
|
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 **
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
findings behind it are in
|
|
587
|
-
[docs/findings.md](../../docs/findings.md)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
|
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
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().
|