janela 0.13.0 → 0.14.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 +22 -14
- package/bin/janela.mjs +45 -292
- package/bin/lib.mjs +347 -0
- package/package.json +11 -3
- package/runtime/janela.ts +11 -18
- package/shim/android/app.cc +9 -0
- package/shim/ios/app.cc +10 -0
- package/templates/main.ts +0 -4
- package/templates/react/files/src-host/main.ts +2 -5
- package/templates/solid/files/src-host/main.ts +2 -5
- package/templates/svelte/files/src-host/main.ts +2 -5
- package/templates/vue/files/src-host/main.ts +2 -5
package/README.md
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
Desktop and mobile apps in pure TypeScript, compiled to native. No Rust, no
|
|
6
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
|
-
around 400
|
|
8
|
+
[webview/webview](https://github.com/webview/webview). A desktop binary comes
|
|
9
|
+
out around 200–400 KB — 191 KB for the smallest template — with no bundled
|
|
10
|
+
browser and no bundled runtime; iOS and Android bundles land around
|
|
11
|
+
384–578 KB. Per-template figures are in
|
|
12
|
+
[docs/frontend.md](../../docs/frontend.md).
|
|
10
13
|
|
|
11
14
|
Five targets, one runtime — the same `main.ts`, the same typed contract and the
|
|
12
15
|
same frontend build for each:
|
|
@@ -542,7 +545,7 @@ app.command("add", (args) => {
|
|
|
542
545
|
Mechanically: drop the `JSON.parse(argsJson)` (cast `args` instead), drop
|
|
543
546
|
every `JSON.stringify` around a result, `resolve`/`reject`/`emit` payload, and
|
|
544
547
|
return nothing at all where you used to return `"null"`. Requires Node 24 to
|
|
545
|
-
build (scriptc 0.0.
|
|
548
|
+
build (scriptc 0.0.36's floor).
|
|
546
549
|
|
|
547
550
|
## What the CLI hides
|
|
548
551
|
|
|
@@ -559,10 +562,6 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
|
|
|
559
562
|
the boundary, so anything that survives `JSON.stringify`/`JSON.parse` round
|
|
560
563
|
trips (including full Unicode). `args` is typed `unknown` — cast it to the
|
|
561
564
|
shape you expect.
|
|
562
|
-
- Never use a bare FFI call as a complete variable initializer or assignment
|
|
563
|
-
RHS — it is silently miscompiled. Wrap it in any expression (`+ 0`). Plain
|
|
564
|
-
TypeScript is unaffected; only the runtime does FFI, so app code rarely
|
|
565
|
-
meets this.
|
|
566
565
|
- One window per app for now. Host code is single-threaded: a synchronous
|
|
567
566
|
command blocks the UI while it runs — use `commandAsync` + `defer`/`sleep`
|
|
568
567
|
(see "Async commands") for anything slow.
|
|
@@ -583,8 +582,10 @@ It is **simulator-only** so far — device builds and code signing are not
|
|
|
583
582
|
wired up yet. Commands, the typed
|
|
584
583
|
contract, events, Vite frontends, async commands (`commandAsync`, `defer`,
|
|
585
584
|
`sleep`) and file I/O all work the same as on desktop — the shell owns the
|
|
586
|
-
clock and the file queue on both.
|
|
587
|
-
|
|
585
|
+
clock and the file queue on both. `openFileDialog` works as of 0.13.0 — the
|
|
586
|
+
picked file is copied into the app container and that path returned, so
|
|
587
|
+
`readFileAsync` opens it exactly as on desktop. `saveFileDialog` reports
|
|
588
|
+
clearly when called, and window control is a no-op there by nature. See
|
|
588
589
|
[docs/ios.md](../../docs/ios.md).
|
|
589
590
|
|
|
590
591
|
## Android
|
|
@@ -615,11 +616,18 @@ The design notes and scriptc findings behind it are in
|
|
|
615
616
|
[docs/findings.md](../../docs/findings.md), with per-platform notes in
|
|
616
617
|
[docs/ios.md](../../docs/ios.md) and [docs/android.md](../../docs/android.md).
|
|
617
618
|
|
|
618
|
-
Not yet:
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
619
|
+
Not yet: `saveFileDialog` and window control on mobile (both deliberate —
|
|
620
|
+
mobile "save" means exporting a file that already exists, which the desktop
|
|
621
|
+
signature cannot express, and window geometry is meaningless on a phone);
|
|
622
|
+
iOS device builds and code signing; installers and notarization; async
|
|
623
|
+
commands that run in parallel (host code is single-threaded, so
|
|
624
|
+
`commandAsync` interleaves and a CPU-bound handler still needs slicing); an
|
|
625
|
+
async HTTP client; tray icons and menus; multi-window; directory picking on
|
|
626
|
+
Windows and iOS; and `app.center()`.
|
|
627
|
+
|
|
628
|
+
`openFileDialog` **does** work on iOS and Android as of 0.13.0 — a picked
|
|
629
|
+
file is copied into the app container and that path returned, so
|
|
630
|
+
`readFileAsync` opens it exactly as on desktop.
|
|
623
631
|
|
|
624
632
|
## Releasing
|
|
625
633
|
|
package/bin/janela.mjs
CHANGED
|
@@ -18,6 +18,11 @@ import { createServer } from "node:net";
|
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
19
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
+
import {
|
|
22
|
+
ANDROID_ABI, ANDROID_TARGET_SDK, androidConf, ffiManifest, iosConf,
|
|
23
|
+
libraryProfile, mimeFor, NAME_RE, patchPeSubsystem, PeError,
|
|
24
|
+
rewriteHostSpecifier, suggestName,
|
|
25
|
+
} from "./lib.mjs";
|
|
21
26
|
|
|
22
27
|
const KIT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
28
|
const require = createRequire(join(KIT, "package.json"));
|
|
@@ -81,30 +86,28 @@ function viteConfigPath(root) {
|
|
|
81
86
|
return null;
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
// How to run the project's own vite.
|
|
90
|
+
//
|
|
91
|
+
// The `.bin` entry is a .cmd shim on Windows and, since the CVE-2024-27980
|
|
92
|
+
// fix, Node refuses to spawn a .cmd without a shell. `janela dev` passed
|
|
93
|
+
// `shell: true` and worked; `janela build` did not, so building any Vite
|
|
94
|
+
// template on Windows died with `spawnSync ...\vite.cmd EINVAL`. Prefer
|
|
95
|
+
// vite's own JS entry run with this Node: no shim, no shell, and identical on
|
|
96
|
+
// every platform. The shim stays as a fallback for layouts that hide the
|
|
97
|
+
// package but keep the bin.
|
|
98
|
+
function viteCommand(root) {
|
|
99
|
+
const js = join(root, "node_modules", "vite", "bin", "vite.js");
|
|
100
|
+
if (existsSync(js)) return { argv: [process.execPath, js], shell: false };
|
|
101
|
+
|
|
102
|
+
const shim = join(root, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
|
|
103
|
+
if (existsSync(shim)) return { argv: [shim], shell: process.platform === "win32" };
|
|
104
|
+
|
|
105
|
+
fail(
|
|
106
|
+
"this project has a vite config but no local vite — run your package manager's " +
|
|
107
|
+
"install first (npm install / pnpm install)",
|
|
108
|
+
);
|
|
93
109
|
}
|
|
94
110
|
|
|
95
|
-
const MIME = {
|
|
96
|
-
".css": "text/css", ".js": "text/javascript", ".json": "application/json",
|
|
97
|
-
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
98
|
-
".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
|
|
99
|
-
".svg": "image/svg+xml", ".ico": "image/x-icon",
|
|
100
|
-
".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf",
|
|
101
|
-
".mp3": "audio/mpeg", ".mp4": "video/mp4", ".webm": "video/webm",
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
function mimeFor(p) {
|
|
105
|
-
const dot = p.lastIndexOf(".");
|
|
106
|
-
return (dot < 0 ? null : MIME[p.slice(dot).toLowerCase()]) ?? "application/octet-stream";
|
|
107
|
-
}
|
|
108
111
|
|
|
109
112
|
// A dist-relative reference ("/assets/x.js", "./assets/x.js") → absolute path,
|
|
110
113
|
// or null when it points outside the build (a CDN URL, a data: URI, an anchor).
|
|
@@ -186,7 +189,8 @@ function frontendHtml(root, conf, devUrl) {
|
|
|
186
189
|
|
|
187
190
|
if (viteConfigPath(root)) {
|
|
188
191
|
console.log("janela: building the frontend with vite");
|
|
189
|
-
|
|
192
|
+
const viteCmd = viteCommand(root);
|
|
193
|
+
run([...viteCmd.argv, "build"], { cwd: root, shell: viteCmd.shell });
|
|
190
194
|
const distDir = resolve(root, conf.frontend?.dist ?? "dist");
|
|
191
195
|
const html = inlineDist(distDir);
|
|
192
196
|
console.log(`janela: frontend inlined (${(Buffer.byteLength(html) / 1024).toFixed(0)} kB)`);
|
|
@@ -274,8 +278,6 @@ function webview2Include(cacheDir) {
|
|
|
274
278
|
// Everything below is simulator-only: a device build additionally needs a
|
|
275
279
|
// signing identity and a provisioning profile, which is its own project.
|
|
276
280
|
|
|
277
|
-
const IOS_MIN_VERSION = "15.0";
|
|
278
|
-
const IOS_PREFIX = "jl_";
|
|
279
281
|
|
|
280
282
|
function iosDeviceOrFail(name) {
|
|
281
283
|
const json = capture(["xcrun", "simctl", "list", "devices", "available", "--json"]);
|
|
@@ -296,86 +298,9 @@ function iosDeviceOrFail(name) {
|
|
|
296
298
|
return pick;
|
|
297
299
|
}
|
|
298
300
|
|
|
299
|
-
function iosConf(conf) {
|
|
300
|
-
const ios = conf.ios ?? {};
|
|
301
|
-
return {
|
|
302
|
-
identifier: ios.identifier ?? conf.identifier,
|
|
303
|
-
displayName: ios.displayName ?? conf.window?.title ?? conf.name,
|
|
304
|
-
minimumVersion: String(ios.minimumVersion ?? IOS_MIN_VERSION),
|
|
305
|
-
device: ios.device ?? null,
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
|
|
309
301
|
// The library profile: one export carries every command, so a project's own
|
|
310
302
|
// commands need no ABI of their own. `janelaEmit` is the reverse channel the
|
|
311
303
|
// shell registers before init.
|
|
312
|
-
function libraryProfile() {
|
|
313
|
-
return {
|
|
314
|
-
profile_format: 1,
|
|
315
|
-
name: "janela",
|
|
316
|
-
entry: "./entry.ts",
|
|
317
|
-
emission: "llvm",
|
|
318
|
-
abi: {
|
|
319
|
-
prefix: IOS_PREFIX,
|
|
320
|
-
init_symbol: `${IOS_PREFIX}init`,
|
|
321
|
-
sink_register_symbol: `${IOS_PREFIX}set_panic_sink`,
|
|
322
|
-
collect_symbol: `${IOS_PREFIX}collect`,
|
|
323
|
-
result_reset_symbol: `${IOS_PREFIX}reset`,
|
|
324
|
-
callback_register_symbol: `${IOS_PREFIX}set_callback`,
|
|
325
|
-
},
|
|
326
|
-
exports: [
|
|
327
|
-
{
|
|
328
|
-
export: "handleInvoke",
|
|
329
|
-
symbol: `${IOS_PREFIX}handle_invoke`,
|
|
330
|
-
params: ["string", "string"],
|
|
331
|
-
returns: "string",
|
|
332
|
-
},
|
|
333
|
-
{
|
|
334
|
-
export: "indexHtml",
|
|
335
|
-
symbol: `${IOS_PREFIX}index_html`,
|
|
336
|
-
params: [],
|
|
337
|
-
returns: "string",
|
|
338
|
-
},
|
|
339
|
-
// The shell calls these back on the main queue when work it owns comes
|
|
340
|
-
// due. They are the mirror of the desktop shim's wv_on_timer: the
|
|
341
|
-
// library parks a continuation under an id and is re-entered with it.
|
|
342
|
-
{
|
|
343
|
-
export: "onTimer",
|
|
344
|
-
symbol: `${IOS_PREFIX}on_timer`,
|
|
345
|
-
params: ["f64"],
|
|
346
|
-
returns: "void",
|
|
347
|
-
},
|
|
348
|
-
{
|
|
349
|
-
export: "onFsDone",
|
|
350
|
-
symbol: `${IOS_PREFIX}on_fs_done`,
|
|
351
|
-
params: ["f64", "bool", "string"],
|
|
352
|
-
returns: "void",
|
|
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
|
-
},
|
|
363
|
-
],
|
|
364
|
-
// TS -> shell. A channel handler must never re-enter the library (see
|
|
365
|
-
// upstream #263: violations silently appear to work), so every one of
|
|
366
|
-
// these only records the request; the shell acts on its own queue and
|
|
367
|
-
// re-enters through an export above on a later turn.
|
|
368
|
-
callbacks: [
|
|
369
|
-
{ name: "janelaEmit", params: ["string", "string"], returns: "void" },
|
|
370
|
-
{ name: "hostSchedule", params: ["f64", "f64"], returns: "void" },
|
|
371
|
-
{ name: "hostSettle", params: ["f64", "string"], returns: "void" },
|
|
372
|
-
{ name: "hostReadFile", params: ["f64", "string"], returns: "void" },
|
|
373
|
-
{ name: "hostWriteFile", params: ["f64", "string", "string"], returns: "void" },
|
|
374
|
-
{ name: "hostOpenDialog", params: ["f64", "string"], returns: "void" },
|
|
375
|
-
],
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
|
|
379
304
|
function iosPlist(conf, iconFiles = []) {
|
|
380
305
|
const ios = iosConf(conf);
|
|
381
306
|
// The asset-catalogue route needs actool; the CFBundleIconFiles list is the
|
|
@@ -498,34 +423,11 @@ async function devIos(root) {
|
|
|
498
423
|
// backend needs a companion class because android.webkit.WebView is a Java API
|
|
499
424
|
// and native code cannot define a class to receive its callbacks.
|
|
500
425
|
|
|
501
|
-
const ANDROID_MIN_SDK = 26;
|
|
502
|
-
const ANDROID_TARGET_SDK = 34;
|
|
503
|
-
const ANDROID_ABI = "arm64-v8a";
|
|
504
426
|
|
|
505
427
|
/// Android package names are Java package names: dot-separated identifiers,
|
|
506
428
|
/// so no hyphens. A janela project may be called `my-app`, which makes the
|
|
507
429
|
/// default identifier `dev.janela.my-app` — legal everywhere else and not
|
|
508
430
|
/// here, so each segment is coerced rather than failing the build.
|
|
509
|
-
function androidPackage(id) {
|
|
510
|
-
return id
|
|
511
|
-
.split(".")
|
|
512
|
-
.map((seg) => {
|
|
513
|
-
const cleaned = seg.replace(/[^A-Za-z0-9_]/g, "_");
|
|
514
|
-
return /^[A-Za-z_]/.test(cleaned) ? cleaned : `_${cleaned}`;
|
|
515
|
-
})
|
|
516
|
-
.join(".");
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function androidConf(conf) {
|
|
520
|
-
const a = conf.android ?? {};
|
|
521
|
-
return {
|
|
522
|
-
applicationId: androidPackage(a.applicationId ?? a.identifier ?? conf.identifier),
|
|
523
|
-
label: a.label ?? conf.window?.title ?? conf.name,
|
|
524
|
-
minSdk: String(a.minSdk ?? ANDROID_MIN_SDK),
|
|
525
|
-
device: a.device ?? null,
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
|
|
529
431
|
/// The SDK pieces an Android build needs, or a message saying which is absent.
|
|
530
432
|
function androidSdk() {
|
|
531
433
|
const home =
|
|
@@ -835,128 +737,6 @@ function buildShim(cacheDir) {
|
|
|
835
737
|
|
|
836
738
|
// ---- FFI manifest ---------------------------------------------------------
|
|
837
739
|
|
|
838
|
-
const STR = (name, symbol) => ({ name, symbol, params: ["i32", "string"], returns: "i32" });
|
|
839
|
-
|
|
840
|
-
function ffiManifest(shimLib) {
|
|
841
|
-
const functions = [
|
|
842
|
-
{ name: "wvCreate", symbol: "wv_create", params: ["i32"], returns: "i32" },
|
|
843
|
-
STR("wvSetTitle", "wv_set_title"),
|
|
844
|
-
{ name: "wvSetSize", symbol: "wv_set_size", params: ["i32", "i32", "i32", "i32"], returns: "i32" },
|
|
845
|
-
STR("wvSetHtml", "wv_set_html"),
|
|
846
|
-
STR("wvInit", "wv_init"),
|
|
847
|
-
STR("wvEval", "wv_eval"),
|
|
848
|
-
STR("wvBind", "wv_bind"),
|
|
849
|
-
STR("wvReply", "wv_reply"),
|
|
850
|
-
// Retained handlers (format 4): registered once, valid for the app's
|
|
851
|
-
// lifetime, so wv_run is a plain blocking call. The request rides in as a
|
|
852
|
-
// `string` param (format 3) rather than a byte-at-a-time drain.
|
|
853
|
-
{
|
|
854
|
-
name: "wvOnInvoke", symbol: "wv_on_invoke",
|
|
855
|
-
params: [
|
|
856
|
-
"i32",
|
|
857
|
-
{ callback: { id: "inv", params: ["string", { context: "inv" }], returns: "i32", lifetime: "retained" } },
|
|
858
|
-
{ context: "inv" },
|
|
859
|
-
],
|
|
860
|
-
returns: "i32",
|
|
861
|
-
},
|
|
862
|
-
{
|
|
863
|
-
name: "wvOnTimer", symbol: "wv_on_timer",
|
|
864
|
-
params: [
|
|
865
|
-
"i32",
|
|
866
|
-
{ callback: { id: "timer", params: ["i32", { context: "timer" }], returns: "void", lifetime: "retained" } },
|
|
867
|
-
{ context: "timer" },
|
|
868
|
-
],
|
|
869
|
-
returns: "i32",
|
|
870
|
-
},
|
|
871
|
-
{ name: "wvRun", symbol: "wv_run", params: ["i32"], returns: "i32" },
|
|
872
|
-
{ name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
|
|
873
|
-
// async: the held-reply table (deferred returns) plus shell-owned
|
|
874
|
-
// scheduling — TS parks a continuation id, the shell calls it back due.
|
|
875
|
-
{ name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
|
|
876
|
-
{ name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
|
|
877
|
-
{ name: "wvSchedule", symbol: "wv_schedule", params: ["i32", "i32", "i32"], returns: "i32" },
|
|
878
|
-
// async file I/O: the blocking syscall runs on a shim worker thread
|
|
879
|
-
{ name: "wvFsRead", symbol: "wv_fs_read", params: ["i32", "string"], returns: "i32" },
|
|
880
|
-
{ name: "wvFsWrite", symbol: "wv_fs_write", params: ["i32", "string", "string"], returns: "i32" },
|
|
881
|
-
// Job accessors, shared by file I/O and dialogs: both are work whose
|
|
882
|
-
// answer cannot be produced during the FFI call that starts it.
|
|
883
|
-
{ name: "wvJobStatus", symbol: "wv_job_status", params: ["i32", "i32"], returns: "i32" },
|
|
884
|
-
{ name: "wvJobSize", symbol: "wv_job_size", params: ["i32", "i32"], returns: "f64" },
|
|
885
|
-
{
|
|
886
|
-
// One slice per call, so a large payload decodes across several UI turns
|
|
887
|
-
// instead of stalling on all of it at once. Returns the bytes covered.
|
|
888
|
-
name: "wvJobTakeAt", symbol: "wv_job_take_at",
|
|
889
|
-
params: [
|
|
890
|
-
"i32", "i32", "f64", "f64",
|
|
891
|
-
{ callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
|
|
892
|
-
{ context: "sink" },
|
|
893
|
-
],
|
|
894
|
-
returns: "f64",
|
|
895
|
-
},
|
|
896
|
-
{ name: "wvJobFree", symbol: "wv_job_free", params: ["i32", "i32"], returns: "i32" },
|
|
897
|
-
// Native dialogs: the modal runs on a later UI-thread turn, so asking for
|
|
898
|
-
// one never blocks the invoke that asked. Options ride as plain params
|
|
899
|
-
// (kind, flags, title, defaultPath, defaultName, filters).
|
|
900
|
-
{
|
|
901
|
-
name: "wvDialog", symbol: "wv_dialog",
|
|
902
|
-
params: ["i32", "i32", "i32", "string", "string", "string", "string"],
|
|
903
|
-
returns: "i32",
|
|
904
|
-
},
|
|
905
|
-
{ name: "wvSetFullscreen", symbol: "wv_set_fullscreen", params: ["i32", "i32"], returns: "i32" },
|
|
906
|
-
];
|
|
907
|
-
|
|
908
|
-
if (process.platform === "win32") {
|
|
909
|
-
// MinGW ignores MSVC's #pragma comment(lib, ...), so the Win32 imports the
|
|
910
|
-
// WebView2 backend needs are named explicitly. scriptc's own win32 lane
|
|
911
|
-
// already adds advapi32/iphlpapi/ws2_32, so those are omitted here.
|
|
912
|
-
// `c++` pulls libc++ for the shim's std::string/exceptions.
|
|
913
|
-
//
|
|
914
|
-
// `pthread` (mingw's libwinpthread) is here to work around an upstream
|
|
915
|
-
// scriptc bug: its runtime calls clock_gettime/nanosleep, which mingw
|
|
916
|
-
// declares in <time.h> but implements in winpthreads, and scriptc's win32
|
|
917
|
-
// link never adds it. Without this the link dies with
|
|
918
|
-
// "undefined symbol: clock_gettime" — reproducible with a plain
|
|
919
|
-
// `scriptc build hello.ts` on Windows, no FFI involved.
|
|
920
|
-
return {
|
|
921
|
-
ffi_format: 4,
|
|
922
|
-
functions,
|
|
923
|
-
libraries: [shimLib],
|
|
924
|
-
system_libraries: [
|
|
925
|
-
"c++", "pthread",
|
|
926
|
-
"ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
|
|
927
|
-
// GetOpenFileNameW / GetSaveFileNameW for the native file dialogs.
|
|
928
|
-
"comdlg32",
|
|
929
|
-
],
|
|
930
|
-
};
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
if (process.platform === "darwin") {
|
|
934
|
-
// scriptc has no -framework support, but `libraries` entries are passed to
|
|
935
|
-
// the link as plain input files and ld64 accepts .tbd stubs.
|
|
936
|
-
const sdk = capture(["xcrun", "--sdk", "macosx", "--show-sdk-path"]);
|
|
937
|
-
return {
|
|
938
|
-
ffi_format: 4,
|
|
939
|
-
functions,
|
|
940
|
-
libraries: [
|
|
941
|
-
shimLib,
|
|
942
|
-
join(sdk, "System/Library/Frameworks/WebKit.framework/WebKit.tbd"),
|
|
943
|
-
join(sdk, "System/Library/Frameworks/Cocoa.framework/Cocoa.tbd"),
|
|
944
|
-
],
|
|
945
|
-
system_libraries: ["c++"],
|
|
946
|
-
};
|
|
947
|
-
}
|
|
948
|
-
return {
|
|
949
|
-
ffi_format: 4,
|
|
950
|
-
functions,
|
|
951
|
-
libraries: [shimLib],
|
|
952
|
-
system_libraries: [
|
|
953
|
-
"stdc++", "webkit2gtk-4.1", "javascriptcoregtk-4.1", "gtk-3", "gdk-3",
|
|
954
|
-
"soup-3.0", "gio-2.0", "gobject-2.0", "glib-2.0", "gmodule-2.0",
|
|
955
|
-
"pango-1.0", "pangocairo-1.0", "harfbuzz", "atk-1.0", "cairo",
|
|
956
|
-
"cairo-gobject", "gdk_pixbuf-2.0", "z", "pthread",
|
|
957
|
-
],
|
|
958
|
-
};
|
|
959
|
-
}
|
|
960
740
|
|
|
961
741
|
// ---- Windows subsystem ------------------------------------------------------
|
|
962
742
|
|
|
@@ -972,34 +752,16 @@ function ffiManifest(shimLib) {
|
|
|
972
752
|
// the loader whether to allocate a console. Every offset is checked before
|
|
973
753
|
// anything is written, and a file that does not look like a console-subsystem
|
|
974
754
|
// PE is left alone.
|
|
975
|
-
const IMAGE_SUBSYSTEM_WINDOWS_GUI = 2;
|
|
976
|
-
const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
|
|
977
755
|
|
|
978
756
|
function makeGuiSubsystem(exePath) {
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
757
|
+
let result;
|
|
758
|
+
try {
|
|
759
|
+
result = patchPeSubsystem(readFileSync(exePath));
|
|
760
|
+
} catch (e) {
|
|
761
|
+
if (e instanceof PeError) fail(`${exePath} ${e.message}`);
|
|
762
|
+
throw e;
|
|
982
763
|
}
|
|
983
|
-
|
|
984
|
-
if (peOff + 24 > buf.length || buf.readUInt32LE(peOff) !== 0x00004550) {
|
|
985
|
-
fail(`${exePath} has no PE signature at ${peOff}`);
|
|
986
|
-
}
|
|
987
|
-
// Optional header starts after the 4-byte signature and 20-byte COFF header;
|
|
988
|
-
// Subsystem sits at +68 in both PE32 (0x10b) and PE32+ (0x20b).
|
|
989
|
-
const optOff = peOff + 24;
|
|
990
|
-
const magic = buf.readUInt16LE(optOff);
|
|
991
|
-
if (magic !== 0x10b && magic !== 0x20b) {
|
|
992
|
-
fail(`${exePath} has an unrecognised optional header magic 0x${magic.toString(16)}`);
|
|
993
|
-
}
|
|
994
|
-
const subOff = optOff + 68;
|
|
995
|
-
if (subOff + 2 > buf.length) fail(`${exePath} is truncated before its Subsystem field`);
|
|
996
|
-
const current = buf.readUInt16LE(subOff);
|
|
997
|
-
if (current === IMAGE_SUBSYSTEM_WINDOWS_GUI) return;
|
|
998
|
-
if (current !== IMAGE_SUBSYSTEM_WINDOWS_CUI) {
|
|
999
|
-
fail(`${exePath} has an unexpected subsystem ${current}; refusing to rewrite it`);
|
|
1000
|
-
}
|
|
1001
|
-
buf.writeUInt16LE(IMAGE_SUBSYSTEM_WINDOWS_GUI, subOff);
|
|
1002
|
-
writeFileSync(exePath, buf);
|
|
764
|
+
if (result.patched) writeFileSync(exePath, result.buf);
|
|
1003
765
|
}
|
|
1004
766
|
|
|
1005
767
|
// ---- build ----------------------------------------------------------------
|
|
@@ -1187,10 +949,7 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
|
|
|
1187
949
|
// as well.
|
|
1188
950
|
writeFileSync(
|
|
1189
951
|
join(buildDir, "main.ts"),
|
|
1190
|
-
readFileSync(mainSrc, "utf8")
|
|
1191
|
-
/(\bfrom\s*)(['"])janela\/host\2/g,
|
|
1192
|
-
"$1$2./janela$2",
|
|
1193
|
-
),
|
|
952
|
+
rewriteHostSpecifier(readFileSync(mainSrc, "utf8")),
|
|
1194
953
|
);
|
|
1195
954
|
|
|
1196
955
|
const html = frontendHtml(root, conf, devUrl);
|
|
@@ -1235,7 +994,7 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
|
|
|
1235
994
|
`}\n`
|
|
1236
995
|
: `const app = createApp<CmdsOf<typeof setup>, EvtsOf<typeof setup>>(WINDOW);\n` +
|
|
1237
996
|
`setup(app);\n` +
|
|
1238
|
-
`const rc = app.run(INDEX_HTML)
|
|
997
|
+
`const rc = app.run(INDEX_HTML);\n` +
|
|
1239
998
|
`console.log("[janela] run returned", rc);\n`;
|
|
1240
999
|
|
|
1241
1000
|
writeFileSync(
|
|
@@ -1261,7 +1020,12 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
|
|
|
1261
1020
|
if (ios) return buildIos(root, conf, buildDir, outDir);
|
|
1262
1021
|
if (android) return buildAndroid(root, conf, buildDir, outDir);
|
|
1263
1022
|
|
|
1264
|
-
writeFileSync(join(buildDir, "janela.ffi.json"), JSON.stringify(ffiManifest(shimLib
|
|
1023
|
+
writeFileSync(join(buildDir, "janela.ffi.json"), JSON.stringify(ffiManifest(shimLib, {
|
|
1024
|
+
macSdkPath:
|
|
1025
|
+
process.platform === "darwin"
|
|
1026
|
+
? capture(["xcrun", "--sdk", "macosx", "--show-sdk-path"])
|
|
1027
|
+
: null,
|
|
1028
|
+
}), null, 2) + "\n");
|
|
1265
1029
|
|
|
1266
1030
|
console.log("janela: compiling TypeScript to a native binary");
|
|
1267
1031
|
// An explicit --out is used verbatim, so the PE suffix is ours to add.
|
|
@@ -1374,20 +1138,9 @@ function copyTemplate(from, to, name) {
|
|
|
1374
1138
|
// allowed because people type them and every downstream use accepts them —
|
|
1375
1139
|
// Android application ids in particular *prefer* them, since a Java package
|
|
1376
1140
|
// segment cannot contain a hyphen (see androidApplicationId).
|
|
1377
|
-
const NAME_RE = /^[a-z][a-z0-9_-]*$/;
|
|
1378
1141
|
|
|
1379
1142
|
// Best-effort repair of a rejected name, so the error can suggest something
|
|
1380
1143
|
// 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
|
-
|
|
1391
1144
|
function init(name, template) {
|
|
1392
1145
|
if (!name) {
|
|
1393
1146
|
fail(
|
|
@@ -1484,11 +1237,11 @@ async function dev(root) {
|
|
|
1484
1237
|
const port = await freePort();
|
|
1485
1238
|
devUrl = `http://localhost:${port}/`;
|
|
1486
1239
|
console.log(`janela: starting the vite dev server on ${devUrl}`);
|
|
1487
|
-
|
|
1240
|
+
const viteCmd = viteCommand(root);
|
|
1241
|
+
vite = spawn(viteCmd.argv[0], [...viteCmd.argv.slice(1), "--port", String(port), "--strictPort"], {
|
|
1488
1242
|
cwd: root,
|
|
1489
1243
|
stdio: "inherit",
|
|
1490
|
-
|
|
1491
|
-
shell: process.platform === "win32",
|
|
1244
|
+
shell: viteCmd.shell,
|
|
1492
1245
|
});
|
|
1493
1246
|
const stop = () => { if (vite && vite.exitCode === null) vite.kill(); };
|
|
1494
1247
|
process.on("exit", stop);
|
package/bin/lib.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers shared by the CLI and its tests.
|
|
3
|
+
*
|
|
4
|
+
* `bin/janela.mjs` is build orchestration: it shells out, writes files and
|
|
5
|
+
* exits. The decisions inside it — how a name is validated, how a config's
|
|
6
|
+
* defaults fall back, which symbols an FFI manifest must declare, which bytes
|
|
7
|
+
* of a PE may be rewritten — are pure, and a wrong answer from any of them
|
|
8
|
+
* fails quietly rather than loudly: a missing FFI declaration makes the
|
|
9
|
+
* runtime fail to link, and a mis-coerced Android id is rejected by aapt2
|
|
10
|
+
* only at package time.
|
|
11
|
+
*
|
|
12
|
+
* So they live here, where a test can call them directly. Nothing in this
|
|
13
|
+
* file touches the filesystem, the network or a subprocess.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
// ---- project names ---------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export const NAME_RE = /^[a-z][a-z0-9_-]*$/;
|
|
21
|
+
|
|
22
|
+
export const MIME = {
|
|
23
|
+
".css": "text/css", ".js": "text/javascript", ".json": "application/json",
|
|
24
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
25
|
+
".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
|
|
26
|
+
".svg": "image/svg+xml", ".ico": "image/x-icon",
|
|
27
|
+
".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf",
|
|
28
|
+
".mp3": "audio/mpeg", ".mp4": "video/mp4", ".webm": "video/webm",
|
|
29
|
+
};
|
|
30
|
+
export const STR = (name, symbol) => ({ name, symbol, params: ["i32", "string"], returns: "i32" });
|
|
31
|
+
export const IOS_MIN_VERSION = "15.0";
|
|
32
|
+
export const IOS_PREFIX = "jl_";
|
|
33
|
+
export const ANDROID_MIN_SDK = 26;
|
|
34
|
+
export const ANDROID_TARGET_SDK = 34;
|
|
35
|
+
export const ANDROID_ABI = "arm64-v8a";
|
|
36
|
+
export const IMAGE_SUBSYSTEM_WINDOWS_GUI = 2;
|
|
37
|
+
export const IMAGE_SUBSYSTEM_WINDOWS_CUI = 3;
|
|
38
|
+
|
|
39
|
+
export function suggestName(raw) {
|
|
40
|
+
const s = String(raw)
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
43
|
+
.replace(/^[^a-z]+/, "")
|
|
44
|
+
.replace(/-{2,}/g, "-")
|
|
45
|
+
.replace(/[-_]+$/, "");
|
|
46
|
+
return NAME_RE.test(s) ? s : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function androidPackage(id) {
|
|
50
|
+
return id
|
|
51
|
+
.split(".")
|
|
52
|
+
.map((seg) => {
|
|
53
|
+
const cleaned = seg.replace(/[^A-Za-z0-9_]/g, "_");
|
|
54
|
+
return /^[A-Za-z_]/.test(cleaned) ? cleaned : `_${cleaned}`;
|
|
55
|
+
})
|
|
56
|
+
.join(".");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function androidConf(conf) {
|
|
60
|
+
const a = conf.android ?? {};
|
|
61
|
+
return {
|
|
62
|
+
applicationId: androidPackage(a.applicationId ?? a.identifier ?? conf.identifier),
|
|
63
|
+
label: a.label ?? conf.window?.title ?? conf.name,
|
|
64
|
+
minSdk: String(a.minSdk ?? ANDROID_MIN_SDK),
|
|
65
|
+
device: a.device ?? null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function iosConf(conf) {
|
|
70
|
+
const ios = conf.ios ?? {};
|
|
71
|
+
return {
|
|
72
|
+
identifier: ios.identifier ?? conf.identifier,
|
|
73
|
+
displayName: ios.displayName ?? conf.window?.title ?? conf.name,
|
|
74
|
+
minimumVersion: String(ios.minimumVersion ?? IOS_MIN_VERSION),
|
|
75
|
+
device: ios.device ?? null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function libraryProfile() {
|
|
80
|
+
return {
|
|
81
|
+
profile_format: 1,
|
|
82
|
+
name: "janela",
|
|
83
|
+
entry: "./entry.ts",
|
|
84
|
+
emission: "llvm",
|
|
85
|
+
abi: {
|
|
86
|
+
prefix: IOS_PREFIX,
|
|
87
|
+
init_symbol: `${IOS_PREFIX}init`,
|
|
88
|
+
sink_register_symbol: `${IOS_PREFIX}set_panic_sink`,
|
|
89
|
+
collect_symbol: `${IOS_PREFIX}collect`,
|
|
90
|
+
result_reset_symbol: `${IOS_PREFIX}reset`,
|
|
91
|
+
callback_register_symbol: `${IOS_PREFIX}set_callback`,
|
|
92
|
+
},
|
|
93
|
+
exports: [
|
|
94
|
+
{
|
|
95
|
+
export: "handleInvoke",
|
|
96
|
+
symbol: `${IOS_PREFIX}handle_invoke`,
|
|
97
|
+
params: ["string", "string"],
|
|
98
|
+
returns: "string",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
export: "indexHtml",
|
|
102
|
+
symbol: `${IOS_PREFIX}index_html`,
|
|
103
|
+
params: [],
|
|
104
|
+
returns: "string",
|
|
105
|
+
},
|
|
106
|
+
// The shell calls these back on the main queue when work it owns comes
|
|
107
|
+
// due. They are the mirror of the desktop shim's wv_on_timer: the
|
|
108
|
+
// library parks a continuation under an id and is re-entered with it.
|
|
109
|
+
{
|
|
110
|
+
export: "onTimer",
|
|
111
|
+
symbol: `${IOS_PREFIX}on_timer`,
|
|
112
|
+
params: ["f64"],
|
|
113
|
+
returns: "void",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
export: "onFsDone",
|
|
117
|
+
symbol: `${IOS_PREFIX}on_fs_done`,
|
|
118
|
+
params: ["f64", "bool", "string"],
|
|
119
|
+
returns: "void",
|
|
120
|
+
},
|
|
121
|
+
// Deliberately its own export rather than reusing onFsDone, whose
|
|
122
|
+
// signature would fit: a dialog result arriving through the file-I/O
|
|
123
|
+
// path would read as a bug for as long as the code lived.
|
|
124
|
+
{
|
|
125
|
+
export: "onDialogDone",
|
|
126
|
+
symbol: `${IOS_PREFIX}on_dialog_done`,
|
|
127
|
+
params: ["f64", "bool", "string"],
|
|
128
|
+
returns: "void",
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
// TS -> shell. A channel handler must never re-enter the library (see
|
|
132
|
+
// upstream #263: violations silently appear to work), so every one of
|
|
133
|
+
// these only records the request; the shell acts on its own queue and
|
|
134
|
+
// re-enters through an export above on a later turn.
|
|
135
|
+
callbacks: [
|
|
136
|
+
{ name: "janelaEmit", params: ["string", "string"], returns: "void" },
|
|
137
|
+
{ name: "hostSchedule", params: ["f64", "f64"], returns: "void" },
|
|
138
|
+
{ name: "hostSettle", params: ["f64", "string"], returns: "void" },
|
|
139
|
+
{ name: "hostReadFile", params: ["f64", "string"], returns: "void" },
|
|
140
|
+
{ name: "hostWriteFile", params: ["f64", "string", "string"], returns: "void" },
|
|
141
|
+
{ name: "hostOpenDialog", params: ["f64", "string"], returns: "void" },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function mimeFor(p) {
|
|
147
|
+
const dot = p.lastIndexOf(".");
|
|
148
|
+
return (dot < 0 ? null : MIME[p.slice(dot).toLowerCase()]) ?? "application/octet-stream";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The FFI manifest a desktop build hands to scriptc: every shim symbol the
|
|
153
|
+
* runtime calls, plus the per-platform link inputs.
|
|
154
|
+
*
|
|
155
|
+
* `platform` and `macSdkPath` are parameters rather than ambient lookups so
|
|
156
|
+
* that every branch is reachable from any host — a manifest that silently
|
|
157
|
+
* loses a declaration fails at link time, on one platform, which is the worst
|
|
158
|
+
* place to find out.
|
|
159
|
+
*/
|
|
160
|
+
export function ffiManifest(shimLib, { platform = process.platform, macSdkPath = null } = {}) {
|
|
161
|
+
const functions = [
|
|
162
|
+
{ name: "wvCreate", symbol: "wv_create", params: ["i32"], returns: "i32" },
|
|
163
|
+
STR("wvSetTitle", "wv_set_title"),
|
|
164
|
+
{ name: "wvSetSize", symbol: "wv_set_size", params: ["i32", "i32", "i32", "i32"], returns: "i32" },
|
|
165
|
+
STR("wvSetHtml", "wv_set_html"),
|
|
166
|
+
STR("wvInit", "wv_init"),
|
|
167
|
+
STR("wvEval", "wv_eval"),
|
|
168
|
+
STR("wvBind", "wv_bind"),
|
|
169
|
+
STR("wvReply", "wv_reply"),
|
|
170
|
+
// Retained handlers (format 4): registered once, valid for the app's
|
|
171
|
+
// lifetime, so wv_run is a plain blocking call. The request rides in as a
|
|
172
|
+
// `string` param (format 3) rather than a byte-at-a-time drain.
|
|
173
|
+
{
|
|
174
|
+
name: "wvOnInvoke", symbol: "wv_on_invoke",
|
|
175
|
+
params: [
|
|
176
|
+
"i32",
|
|
177
|
+
{ callback: { id: "inv", params: ["string", { context: "inv" }], returns: "i32", lifetime: "retained" } },
|
|
178
|
+
{ context: "inv" },
|
|
179
|
+
],
|
|
180
|
+
returns: "i32",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "wvOnTimer", symbol: "wv_on_timer",
|
|
184
|
+
params: [
|
|
185
|
+
"i32",
|
|
186
|
+
{ callback: { id: "timer", params: ["i32", { context: "timer" }], returns: "void", lifetime: "retained" } },
|
|
187
|
+
{ context: "timer" },
|
|
188
|
+
],
|
|
189
|
+
returns: "i32",
|
|
190
|
+
},
|
|
191
|
+
{ name: "wvRun", symbol: "wv_run", params: ["i32"], returns: "i32" },
|
|
192
|
+
{ name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
|
|
193
|
+
// async: the held-reply table (deferred returns) plus shell-owned
|
|
194
|
+
// scheduling — TS parks a continuation id, the shell calls it back due.
|
|
195
|
+
{ name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
|
|
196
|
+
{ name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
|
|
197
|
+
{ name: "wvSchedule", symbol: "wv_schedule", params: ["i32", "i32", "i32"], returns: "i32" },
|
|
198
|
+
// async file I/O: the blocking syscall runs on a shim worker thread
|
|
199
|
+
{ name: "wvFsRead", symbol: "wv_fs_read", params: ["i32", "string"], returns: "i32" },
|
|
200
|
+
{ name: "wvFsWrite", symbol: "wv_fs_write", params: ["i32", "string", "string"], returns: "i32" },
|
|
201
|
+
// Job accessors, shared by file I/O and dialogs: both are work whose
|
|
202
|
+
// answer cannot be produced during the FFI call that starts it.
|
|
203
|
+
{ name: "wvJobStatus", symbol: "wv_job_status", params: ["i32", "i32"], returns: "i32" },
|
|
204
|
+
{ name: "wvJobSize", symbol: "wv_job_size", params: ["i32", "i32"], returns: "f64" },
|
|
205
|
+
{
|
|
206
|
+
// One slice per call, so a large payload decodes across several UI turns
|
|
207
|
+
// instead of stalling on all of it at once. Returns the bytes covered.
|
|
208
|
+
name: "wvJobTakeAt", symbol: "wv_job_take_at",
|
|
209
|
+
params: [
|
|
210
|
+
"i32", "i32", "f64", "f64",
|
|
211
|
+
{ callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
|
|
212
|
+
{ context: "sink" },
|
|
213
|
+
],
|
|
214
|
+
returns: "f64",
|
|
215
|
+
},
|
|
216
|
+
{ name: "wvJobFree", symbol: "wv_job_free", params: ["i32", "i32"], returns: "i32" },
|
|
217
|
+
// Native dialogs: the modal runs on a later UI-thread turn, so asking for
|
|
218
|
+
// one never blocks the invoke that asked. Options ride as plain params
|
|
219
|
+
// (kind, flags, title, defaultPath, defaultName, filters).
|
|
220
|
+
{
|
|
221
|
+
name: "wvDialog", symbol: "wv_dialog",
|
|
222
|
+
params: ["i32", "i32", "i32", "string", "string", "string", "string"],
|
|
223
|
+
returns: "i32",
|
|
224
|
+
},
|
|
225
|
+
{ name: "wvSetFullscreen", symbol: "wv_set_fullscreen", params: ["i32", "i32"], returns: "i32" },
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
if (platform === "win32") {
|
|
229
|
+
// MinGW ignores MSVC's #pragma comment(lib, ...), so the Win32 imports the
|
|
230
|
+
// WebView2 backend needs are named explicitly. scriptc's own win32 lane
|
|
231
|
+
// already adds advapi32/iphlpapi/ws2_32, so those are omitted here.
|
|
232
|
+
// `c++` pulls libc++ for the shim's std::string/exceptions.
|
|
233
|
+
//
|
|
234
|
+
// `pthread` (mingw's libwinpthread) is here to work around an upstream
|
|
235
|
+
// scriptc bug: its runtime calls clock_gettime/nanosleep, which mingw
|
|
236
|
+
// declares in <time.h> but implements in winpthreads, and scriptc's win32
|
|
237
|
+
// link never adds it. Without this the link dies with
|
|
238
|
+
// "undefined symbol: clock_gettime" — reproducible with a plain
|
|
239
|
+
// `scriptc build hello.ts` on Windows, no FFI involved.
|
|
240
|
+
return {
|
|
241
|
+
ffi_format: 4,
|
|
242
|
+
functions,
|
|
243
|
+
libraries: [shimLib],
|
|
244
|
+
system_libraries: [
|
|
245
|
+
"c++", "pthread",
|
|
246
|
+
"ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
|
|
247
|
+
// GetOpenFileNameW / GetSaveFileNameW for the native file dialogs.
|
|
248
|
+
"comdlg32",
|
|
249
|
+
],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (platform === "darwin") {
|
|
254
|
+
// scriptc has no -framework support, but `libraries` entries are passed to
|
|
255
|
+
// the link as plain input files and ld64 accepts .tbd stubs.
|
|
256
|
+
if (!macSdkPath) throw new Error("ffiManifest: macSdkPath is required on darwin");
|
|
257
|
+
const sdk = macSdkPath;
|
|
258
|
+
return {
|
|
259
|
+
ffi_format: 4,
|
|
260
|
+
functions,
|
|
261
|
+
libraries: [
|
|
262
|
+
shimLib,
|
|
263
|
+
join(sdk, "System/Library/Frameworks/WebKit.framework/WebKit.tbd"),
|
|
264
|
+
join(sdk, "System/Library/Frameworks/Cocoa.framework/Cocoa.tbd"),
|
|
265
|
+
],
|
|
266
|
+
system_libraries: ["c++"],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
ffi_format: 4,
|
|
271
|
+
functions,
|
|
272
|
+
libraries: [shimLib],
|
|
273
|
+
system_libraries: [
|
|
274
|
+
"stdc++", "webkit2gtk-4.1", "javascriptcoregtk-4.1", "gtk-3", "gdk-3",
|
|
275
|
+
"soup-3.0", "gio-2.0", "gobject-2.0", "glib-2.0", "gmodule-2.0",
|
|
276
|
+
"pango-1.0", "pangocairo-1.0", "harfbuzz", "atk-1.0", "cairo",
|
|
277
|
+
"cairo-gobject", "gdk_pixbuf-2.0", "z", "pthread",
|
|
278
|
+
],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
// ---- host specifier rewrite -------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Rewrite a project's `from "janela/host"` to the runtime copy the CLI places
|
|
285
|
+
* beside it in `.janela/build/`.
|
|
286
|
+
*
|
|
287
|
+
* A project imports the package specifier so that it resolves in an editor
|
|
288
|
+
* against the installed package; the build compiles against the local copy
|
|
289
|
+
* instead, which keeps it static (no node_modules resolution) and keeps
|
|
290
|
+
* working once "janela/host" exports values and not only types.
|
|
291
|
+
*
|
|
292
|
+
* Deliberately narrow: only a specifier in `from` position, only that exact
|
|
293
|
+
* module, either quote style. A mention inside a comment or a longer
|
|
294
|
+
* specifier such as "janela/host-extras" is left alone.
|
|
295
|
+
*/
|
|
296
|
+
export function rewriteHostSpecifier(src) {
|
|
297
|
+
return src.replace(/(\bfrom\s*)(['"])janela\/host\2/g, "$1$2./janela$2");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---- Windows subsystem ------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
/** A PE the patcher refused to touch, with `code` naming the reason. */
|
|
303
|
+
export class PeError extends Error {
|
|
304
|
+
constructor(code, message) {
|
|
305
|
+
super(message);
|
|
306
|
+
this.code = code;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Flip a console-subsystem PE to the GUI subsystem, in place, on a copy of
|
|
312
|
+
* the caller's buffer.
|
|
313
|
+
*
|
|
314
|
+
* Returns `{ patched: false }` when the image is already GUI, or
|
|
315
|
+
* `{ patched: true, buf }` with the rewritten bytes. Anything that does not
|
|
316
|
+
* look like the console-subsystem PE we just linked throws a `PeError`
|
|
317
|
+
* instead of being modified — the whole point is to touch exactly one field
|
|
318
|
+
* of exactly one shape of file.
|
|
319
|
+
*/
|
|
320
|
+
export function patchPeSubsystem(input) {
|
|
321
|
+
const buf = Buffer.from(input);
|
|
322
|
+
if (buf.length < 0x40 || buf.readUInt16LE(0) !== 0x5a4d) {
|
|
323
|
+
throw new PeError("no-mz", "not a PE image (no MZ header)");
|
|
324
|
+
}
|
|
325
|
+
const peOff = buf.readUInt32LE(0x3c);
|
|
326
|
+
if (peOff + 24 > buf.length || buf.readUInt32LE(peOff) !== 0x00004550) {
|
|
327
|
+
throw new PeError("no-pe", `no PE signature at ${peOff}`);
|
|
328
|
+
}
|
|
329
|
+
// Optional header starts after the 4-byte signature and 20-byte COFF header;
|
|
330
|
+
// Subsystem sits at +68 in both PE32 (0x10b) and PE32+ (0x20b).
|
|
331
|
+
const optOff = peOff + 24;
|
|
332
|
+
const magic = buf.readUInt16LE(optOff);
|
|
333
|
+
if (magic !== 0x10b && magic !== 0x20b) {
|
|
334
|
+
throw new PeError("bad-magic", `unrecognised optional header magic 0x${magic.toString(16)}`);
|
|
335
|
+
}
|
|
336
|
+
const subOff = optOff + 68;
|
|
337
|
+
if (subOff + 2 > buf.length) {
|
|
338
|
+
throw new PeError("truncated", "truncated before its Subsystem field");
|
|
339
|
+
}
|
|
340
|
+
const current = buf.readUInt16LE(subOff);
|
|
341
|
+
if (current === IMAGE_SUBSYSTEM_WINDOWS_GUI) return { patched: false };
|
|
342
|
+
if (current !== IMAGE_SUBSYSTEM_WINDOWS_CUI) {
|
|
343
|
+
throw new PeError("unexpected-subsystem", `unexpected subsystem ${current}; refusing to rewrite it`);
|
|
344
|
+
}
|
|
345
|
+
buf.writeUInt16LE(IMAGE_SUBSYSTEM_WINDOWS_GUI, subOff);
|
|
346
|
+
return { patched: true, buf };
|
|
347
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "janela",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"./package.json": "./package.json"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"scriptc": "0.0.
|
|
24
|
+
"scriptc": "0.0.36"
|
|
25
25
|
},
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"author": "Mateus Lage",
|
|
@@ -56,5 +56,13 @@
|
|
|
56
56
|
"templates/",
|
|
57
57
|
"vendor-webview/core/include/",
|
|
58
58
|
"vendor-webview/LICENSE"
|
|
59
|
-
]
|
|
59
|
+
],
|
|
60
|
+
"scripts": {
|
|
61
|
+
"test": "npm run test:unit && npm run test:types",
|
|
62
|
+
"test:unit": "node --test test/unit/*.test.mjs",
|
|
63
|
+
"test:types": "node test/types/run.mjs"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"typescript": "^5.9.3"
|
|
67
|
+
}
|
|
60
68
|
}
|
package/runtime/janela.ts
CHANGED
|
@@ -4,11 +4,6 @@
|
|
|
4
4
|
// (name, argsJson) envelope, dispatched to handlers registered on the app
|
|
5
5
|
// object. Handlers see decoded values — the runtime owns JSON at the boundary.
|
|
6
6
|
// Backend→frontend events ride wv_eval into the injected bootstrap.
|
|
7
|
-
//
|
|
8
|
-
// NOTE ON STYLE: every FFI call whose result initializes a variable is written
|
|
9
|
-
// `f(...) + 0`. scriptc miscompiles a bare FFI call used as a complete
|
|
10
|
-
// initializer/assignment RHS — still true in 0.0.35, and reported upstream as
|
|
11
|
-
// vercel-labs/scriptc#21. Any enclosing expression is the workaround.
|
|
12
7
|
|
|
13
8
|
declare function wvCreate(debug: number): number;
|
|
14
9
|
declare function wvSetTitle(h: number, title: string): number;
|
|
@@ -202,7 +197,7 @@ export class JanelaAppImpl<
|
|
|
202
197
|
drainSize: number[] = [];
|
|
203
198
|
|
|
204
199
|
constructor(cfg: WindowConfig) {
|
|
205
|
-
const h = wvCreate(0)
|
|
200
|
+
const h = wvCreate(0);
|
|
206
201
|
this.handle = h;
|
|
207
202
|
wvSetTitle(h, cfg.title);
|
|
208
203
|
wvSetSize(h, cfg.width, cfg.height, 0);
|
|
@@ -221,9 +216,7 @@ export class JanelaAppImpl<
|
|
|
221
216
|
this.contIds.push(id);
|
|
222
217
|
this.contFns.push(fn);
|
|
223
218
|
const delay = ms > 0 ? ms : 0;
|
|
224
|
-
|
|
225
|
-
// in every position, and this one is silently dropped without it.
|
|
226
|
-
const rc = wvSchedule(this.handle, id, delay) + 0;
|
|
219
|
+
const rc = wvSchedule(this.handle, id, delay);
|
|
227
220
|
if (rc < 0) console.log("[janela] could not schedule continuation", id);
|
|
228
221
|
}
|
|
229
222
|
|
|
@@ -254,14 +247,14 @@ export class JanelaAppImpl<
|
|
|
254
247
|
*/
|
|
255
248
|
drainSome(): void {
|
|
256
249
|
if (this.drainIds.length === 0) return;
|
|
257
|
-
const started = Date.now()
|
|
250
|
+
const started = Date.now();
|
|
258
251
|
|
|
259
252
|
while (this.drainIds.length > 0) {
|
|
260
253
|
let chunk = "";
|
|
261
254
|
const taken =
|
|
262
255
|
wvJobTakeAt(this.handle, this.drainIds[0], this.drainOff[0], DRAIN_SLICE, (text) => {
|
|
263
256
|
chunk = text;
|
|
264
|
-
})
|
|
257
|
+
});
|
|
265
258
|
|
|
266
259
|
// A negative count means the job vanished; treat the payload as final
|
|
267
260
|
// rather than spinning on it forever.
|
|
@@ -334,7 +327,7 @@ export class JanelaAppImpl<
|
|
|
334
327
|
const doneCbs: FsCallback[] = [];
|
|
335
328
|
const doneOk: boolean[] = [];
|
|
336
329
|
for (let i = 0; i < this.jobIds.length; i++) {
|
|
337
|
-
const st = wvJobStatus(this.handle, this.jobIds[i])
|
|
330
|
+
const st = wvJobStatus(this.handle, this.jobIds[i]);
|
|
338
331
|
if (st === JOB_PENDING) {
|
|
339
332
|
keptIds.push(this.jobIds[i]);
|
|
340
333
|
keptCbs.push(this.jobCbs[i]);
|
|
@@ -355,7 +348,7 @@ export class JanelaAppImpl<
|
|
|
355
348
|
this.drainOk.push(doneOk[i]);
|
|
356
349
|
this.drainParts.push([]);
|
|
357
350
|
this.drainOff.push(0);
|
|
358
|
-
this.drainSize.push(wvJobSize(this.handle, doneIds[i])
|
|
351
|
+
this.drainSize.push(wvJobSize(this.handle, doneIds[i]));
|
|
359
352
|
}
|
|
360
353
|
}
|
|
361
354
|
|
|
@@ -381,7 +374,7 @@ export class JanelaAppImpl<
|
|
|
381
374
|
defaultPath === undefined ? "" : defaultPath,
|
|
382
375
|
defaultName === undefined ? "" : defaultName,
|
|
383
376
|
encodeFilters(filters),
|
|
384
|
-
)
|
|
377
|
+
);
|
|
385
378
|
if (id < 0) {
|
|
386
379
|
this.defer(() => cb(null, "EAGAIN: could not open a dialog"));
|
|
387
380
|
return;
|
|
@@ -452,7 +445,7 @@ export class JanelaAppImpl<
|
|
|
452
445
|
* worker thread; the callback lands on the UI thread on a later turn.
|
|
453
446
|
*/
|
|
454
447
|
readFileAsync(path: string, cb: FsCallback): void {
|
|
455
|
-
const id = wvFsRead(this.handle, path)
|
|
448
|
+
const id = wvFsRead(this.handle, path);
|
|
456
449
|
if (id < 0) {
|
|
457
450
|
this.defer(() => cb("EAGAIN: could not start a read of '" + path + "'", ""));
|
|
458
451
|
return;
|
|
@@ -463,7 +456,7 @@ export class JanelaAppImpl<
|
|
|
463
456
|
|
|
464
457
|
/** Write a file without blocking the window; cb(null) on success. */
|
|
465
458
|
writeFileAsync(path: string, data: string, cb: (err: string | null) => void): void {
|
|
466
|
-
const id = wvFsWrite(this.handle, path, data)
|
|
459
|
+
const id = wvFsWrite(this.handle, path, data);
|
|
467
460
|
if (id < 0) {
|
|
468
461
|
this.defer(() => cb("EAGAIN: could not start a write of '" + path + "'"));
|
|
469
462
|
return;
|
|
@@ -555,7 +548,7 @@ export class JanelaAppImpl<
|
|
|
555
548
|
// Park the page's promise: the shim holds this call's id and
|
|
556
549
|
// answers it when resolve/reject reaches wvResolve, whenever
|
|
557
550
|
// that is. Meanwhile the loop is free to serve other calls.
|
|
558
|
-
const id = wvDefer(h)
|
|
551
|
+
const id = wvDefer(h);
|
|
559
552
|
if (id < 0) {
|
|
560
553
|
wvReply(h, encode("cannot defer command: " + cmd));
|
|
561
554
|
return 1;
|
|
@@ -579,7 +572,7 @@ export class JanelaAppImpl<
|
|
|
579
572
|
|
|
580
573
|
wvBind(h, "__invoke");
|
|
581
574
|
wvSetHtml(h, html);
|
|
582
|
-
const rc = wvRun(h)
|
|
575
|
+
const rc = wvRun(h);
|
|
583
576
|
return rc;
|
|
584
577
|
}
|
|
585
578
|
}
|
package/shim/android/app.cc
CHANGED
|
@@ -328,6 +328,15 @@ void finish_fs(double id, bool ok, std::string payload) {
|
|
|
328
328
|
void host_read_file(void *, double id, const char *path, size_t path_len) {
|
|
329
329
|
std::string p = resolve_path(std::string(path, path_len));
|
|
330
330
|
std::thread([id, p] {
|
|
331
|
+
// As on iOS: a directory opens as an ifstream and reads as empty, which
|
|
332
|
+
// would report success with no content. Desktop answers EISDIR; keep the
|
|
333
|
+
// message identical across platforms.
|
|
334
|
+
struct stat st;
|
|
335
|
+
if (::stat(p.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) {
|
|
336
|
+
finish_fs(id, false,
|
|
337
|
+
"EISDIR: illegal operation on a directory, read '" + p + "'");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
331
340
|
std::ifstream in(p, std::ios::binary);
|
|
332
341
|
if (!in) {
|
|
333
342
|
finish_fs(id, false,
|
package/shim/ios/app.cc
CHANGED
|
@@ -277,6 +277,16 @@ void finish_fs(double id, bool ok, std::string payload) {
|
|
|
277
277
|
void host_read_file(void *, double id, const char *path, size_t path_len) {
|
|
278
278
|
std::string p = resolve_path(std::string(path, path_len));
|
|
279
279
|
dispatch_async(fs_queue(), ^{
|
|
280
|
+
// A directory opens cleanly as an ifstream on Apple platforms and then
|
|
281
|
+
// reads as empty, so without this check readFileAsync would report
|
|
282
|
+
// success with no content. Desktop already answers EISDIR here; the
|
|
283
|
+
// message is kept identical so app code can treat the platforms alike.
|
|
284
|
+
struct stat st;
|
|
285
|
+
if (::stat(p.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) {
|
|
286
|
+
finish_fs(id, false,
|
|
287
|
+
"EISDIR: illegal operation on a directory, read '" + p + "'");
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
280
290
|
std::ifstream in(p, std::ios::binary);
|
|
281
291
|
if (!in) {
|
|
282
292
|
finish_fs(id, false, "ENOENT: no such file or directory, open '" + p + "'");
|
package/templates/main.ts
CHANGED
|
@@ -3,10 +3,6 @@
|
|
|
3
3
|
// Register commands here; the page calls them with `await janela.invoke(name, args)`.
|
|
4
4
|
// Handlers take the arguments as a value and return a value — the runtime owns
|
|
5
5
|
// JSON at the boundary, so there is no parsing or stringifying to do here.
|
|
6
|
-
//
|
|
7
|
-
// Gotcha inherited from scriptc: never use a bare FFI-backed call as a
|
|
8
|
-
// complete variable initializer — wrap it in any expression (`+ 0`). Plain
|
|
9
|
-
// TypeScript like everything in this file is unaffected.
|
|
10
6
|
|
|
11
7
|
import type { JanelaApp } from "janela/host";
|
|
12
8
|
|
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
// names, argument shapes, results and event payloads — with no code
|
|
7
7
|
// generation and nothing to keep in sync.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// wrap it in any expression (`+ 0`);
|
|
12
|
-
// - a command that returns nothing is declared `() => void` and its handler
|
|
13
|
-
// returns `null`; every command answers the page's promise with a value.
|
|
9
|
+
// A command that returns nothing is declared `() => void` and its handler
|
|
10
|
+
// returns `null`; every command answers the page's promise with a value.
|
|
14
11
|
|
|
15
12
|
import type { JanelaApp } from "janela/host";
|
|
16
13
|
|
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
// names, argument shapes, results and event payloads — with no code
|
|
7
7
|
// generation and nothing to keep in sync.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// wrap it in any expression (`+ 0`);
|
|
12
|
-
// - a command that returns nothing is declared `() => void` and its handler
|
|
13
|
-
// returns `null`; every command answers the page's promise with a value.
|
|
9
|
+
// A command that returns nothing is declared `() => void` and its handler
|
|
10
|
+
// returns `null`; every command answers the page's promise with a value.
|
|
14
11
|
|
|
15
12
|
import type { JanelaApp } from "janela/host";
|
|
16
13
|
|
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
// names, argument shapes, results and event payloads — with no code
|
|
7
7
|
// generation and nothing to keep in sync.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// wrap it in any expression (`+ 0`);
|
|
12
|
-
// - a command that returns nothing is declared `() => void` and its handler
|
|
13
|
-
// returns `null`; every command answers the page's promise with a value.
|
|
9
|
+
// A command that returns nothing is declared `() => void` and its handler
|
|
10
|
+
// returns `null`; every command answers the page's promise with a value.
|
|
14
11
|
|
|
15
12
|
import type { JanelaApp } from "janela/host";
|
|
16
13
|
|
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
// names, argument shapes, results and event payloads — with no code
|
|
7
7
|
// generation and nothing to keep in sync.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// wrap it in any expression (`+ 0`);
|
|
12
|
-
// - a command that returns nothing is declared `() => void` and its handler
|
|
13
|
-
// returns `null`; every command answers the page's promise with a value.
|
|
9
|
+
// A command that returns nothing is declared `() => void` and its handler
|
|
10
|
+
// returns `null`; every command answers the page's promise with a value.
|
|
14
11
|
|
|
15
12
|
import type { JanelaApp } from "janela/host";
|
|
16
13
|
|