janela 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,8 +19,33 @@ janela build # .janela/out/my-app (+ my-app.app on macOS)
19
19
  ```
20
20
 
21
21
  Requirements: Node 18+, a C++ compiler (Xcode CLT on macOS; g++ +
22
- `libwebkit2gtk-4.1-dev` on Linux). A worked example lives in
23
- [`examples/demo`](examples/demo) — commands, events, and a file reader.
22
+ `libwebkit2gtk-4.1-dev` on Linux; see [Windows](#windows) below). A worked
23
+ example lives in [`examples/demo`](examples/demo) — commands, events, and a
24
+ file reader.
25
+
26
+ ## Windows
27
+
28
+ `janela build` produces `.janela/out/<name>.exe` using the WebView2 backend.
29
+
30
+ **You need a MinGW-targeting clang on `PATH`** — [llvm-mingw][llvm-mingw],
31
+ MSYS2's `clang64` toolchain, or WinLibs. MSVC does **not** work: scriptc's
32
+ runtime uses POSIX types and calls (`ssize_t`, `nanosleep`, `clock_gettime`)
33
+ that the MSVC CRT does not provide. janela checks `clang -dumpmachine` and
34
+ tells you if the wrong one is first on `PATH`.
35
+
36
+ webview.h's Win32 backend includes `WebView2.h`, which Microsoft ships in a
37
+ nuget package rather than in the Windows SDK, so the first build downloads
38
+ `Microsoft.Web.WebView2` into `.janela/cache/` automatically. To build offline
39
+ or pin your own copy, point `JANELA_WEBVIEW2_INCLUDE` at a directory
40
+ containing `WebView2.h`. No `WebView2Loader.dll` is needed — webview.h has its
41
+ own loader — and end users need only the **WebView2 runtime**, which is
42
+ preinstalled on current Windows 10/11 (it ships with Edge).
43
+
44
+ Two caveats today: the binary is a console-subsystem app, so a console window
45
+ appears behind the UI (handy for `janela dev`, wrong for shipping), and there
46
+ is no installer step — you get a bare `.exe`, not an MSI.
47
+
48
+ [llvm-mingw]: https://github.com/mstorsjo/llvm-mingw/releases
24
49
 
25
50
  ## A project
26
51
 
@@ -44,19 +69,116 @@ Backend API (`src-host/main.ts`):
44
69
  import type { JanelaApp } from "./janela";
45
70
 
46
71
  export function setup(app: JanelaApp): void {
47
- app.command("add", (argsJson) => { // args in / result out as JSON text
48
- const a = JSON.parse(argsJson) as { a: number; b: number };
49
- app.emit("added", JSON.stringify(a.a + a.b)); // push an event to the page
50
- return JSON.stringify(a.a + a.b);
72
+ app.command("add", (args) => { // values in, values out
73
+ const a = args as { a: number; b: number };
74
+ app.emit("added", a.a + a.b); // push an event to the page
75
+ return a.a + a.b;
51
76
  });
52
77
  // app.quit() closes the window and returns from run()
53
78
  }
54
79
  ```
55
80
 
81
+ ## Async commands
82
+
83
+ A command that has to wait — or to chew through real work — should not freeze
84
+ the window. Register it with `commandAsync` and answer whenever you are ready;
85
+ the page keeps using the same `await janela.invoke(...)`.
86
+
87
+ ```ts
88
+ app.commandAsync("wait", (args, resolve, reject) => {
89
+ const a = args as { ms: number };
90
+ app.sleep(a.ms, () => resolve("done")); // resolve later
91
+ });
92
+
93
+ // Work that cannot just wait: slice it, yielding to the UI between slices.
94
+ app.commandAsync("countTo", (args, resolve) => {
95
+ const a = args as { n: number };
96
+ let i = 0;
97
+ const step = (): void => {
98
+ const end = Math.min(i + 2_000_000, a.n);
99
+ for (; i < end; i++) { /* ... */ }
100
+ if (i < a.n) app.defer(step); else resolve(i);
101
+ };
102
+ app.defer(step);
103
+ });
104
+ ```
105
+
106
+ - `app.defer(fn)` — run `fn` on the next turn of the host loop.
107
+ - `app.sleep(ms, fn)` — run `fn` after at least `ms`.
108
+ - `resolve(value)` / `reject(reason)` settle the page's promise; `reject`
109
+ makes `await janela.invoke(...)` throw. Settling twice is ignored.
110
+
111
+ ## File I/O
112
+
113
+ `node:fs` works in a handler, but `readFileSync` **blocks the window** for as
114
+ long as the syscall runs — parking a promise does not change that. Use the
115
+ async pair instead: the syscall runs on a worker thread inside the shim, and
116
+ only the result crosses back to your (single-threaded) TypeScript.
117
+
118
+ ```ts
119
+ app.commandAsync("readFile", (args, resolve) => {
120
+ const a = args as { path: string };
121
+ app.readFileAsync(a.path, (err, text) => {
122
+ if (err !== null) { resolve({ ok: false, error: err }); return; }
123
+ resolve({ ok: true, text });
124
+ });
125
+ });
126
+
127
+ app.writeFileAsync("out.txt", "contents", (err) => { /* err is null on success */ });
128
+ ```
129
+
130
+ Errors arrive as values, never throws — `err` carries a Node-shaped message
131
+ (`ENOENT: no such file or directory, open '/x'`). UTF-8 round-trips exactly,
132
+ astral characters included.
133
+
134
+ The payload still crosses the FFI boundary one byte per call (format 2), which
135
+ costs about **115 ms per MB on the UI thread** — fine for config files and
136
+ documents, wrong for streaming large media. See
137
+ [docs/async.md](../../docs/async.md) for the measurements.
138
+
139
+ **Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
140
+ as long as the program sits inside the `run()` FFI call, so `setTimeout`,
141
+ `queueMicrotask` and `await` in host code never fire while the window is open
142
+ (they all run after it closes). janela supplies its own loop instead: a native
143
+ ticker posts work to the UI thread via `webview_dispatch`, and it only runs
144
+ while something is queued, so an idle app costs nothing.
145
+
146
+ **Still single-threaded.** scriptc's runtime is not thread-safe (concurrent
147
+ calls from several threads abort the process), so host code always runs on the
148
+ UI thread. Async here means *interleaved*, not parallel: a handler that blocks
149
+ without yielding still freezes the window. Slice long work with `defer`.
150
+
56
151
  The backend is ordinary TypeScript with scriptc's stdlib — including a
57
152
  `node:fs` subset — so "read a file" or "call an API" is just code in a
58
153
  command handler, no plugin layer needed.
59
154
 
155
+ ## Migrating from 0.1.x
156
+
157
+ Commands used to take and return **JSON text**; they now take and return
158
+ **values**, with the runtime handling serialisation. The page-side API
159
+ (`janela.invoke` / `janela.listen`) is unchanged.
160
+
161
+ ```ts
162
+ // 0.1.x
163
+ app.command("add", (argsJson) => {
164
+ const a = JSON.parse(argsJson) as { a: number; b: number };
165
+ app.emit("added", JSON.stringify(a.a + a.b));
166
+ return JSON.stringify(a.a + a.b);
167
+ });
168
+
169
+ // 0.2.x
170
+ app.command("add", (args) => {
171
+ const a = args as { a: number; b: number };
172
+ app.emit("added", a.a + a.b);
173
+ return a.a + a.b;
174
+ });
175
+ ```
176
+
177
+ Mechanically: drop the `JSON.parse(argsJson)` (cast `args` instead), drop
178
+ every `JSON.stringify` around a result, `resolve`/`reject`/`emit` payload, and
179
+ return nothing at all where you used to return `"null"`. Requires Node 24 to
180
+ build (scriptc 0.0.35's floor).
181
+
60
182
  ## What the CLI hides
61
183
 
62
184
  `janela build` assembles `.janela/build/` (runtime + your `main.ts` +
@@ -66,18 +188,19 @@ scriptc. On macOS the frameworks are linked as SDK `.tbd` stubs (scriptc has
66
188
  no `-framework` support) and the binary is wrapped into an ad-hoc-signed
67
189
  `.app` bundle.
68
190
 
69
- ## Constraints inherited from scriptc 0.0.32
191
+ ## Constraints inherited from scriptc
70
192
 
71
- - Command args/results cross the boundary as **JSON text** handlers
72
- `JSON.parse` in and `JSON.stringify` out. The runtime keeps the byte channel
73
- ASCII by `\uXXXX`-escaping non-ASCII (scriptc strings can't hold lone
74
- surrogates, and `JSON.parse` is the reliable reassembly point).
193
+ - Command args and results are ordinary values; the runtime serialises them at
194
+ the boundary, so anything that survives `JSON.stringify`/`JSON.parse` round
195
+ trips (including full Unicode). `args` is typed `unknown` — cast it to the
196
+ shape you expect.
75
197
  - Never use a bare FFI call as a complete variable initializer or assignment
76
198
  RHS — it is silently miscompiled. Wrap it in any expression (`+ 0`). Plain
77
199
  TypeScript is unaffected; only the runtime does FFI, so app code rarely
78
200
  meets this.
79
- - One window per app for now; the run loop is single-threaded, so a slow
80
- command blocks the UI (same as a blocking Tauri command handler).
201
+ - One window per app for now. Host code is single-threaded: a synchronous
202
+ command blocks the UI while it runs use `commandAsync` + `defer`/`sleep`
203
+ (see "Async commands") for anything slow.
81
204
  - `console.log` from commands goes to stdout — visible under `janela dev`,
82
205
  not when launched from Finder.
83
206
 
@@ -86,9 +209,28 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
86
209
  Early proof of concept, macOS (arm64) and Linux (WebKitGTK). The design
87
210
  notes and scriptc findings behind it are in
88
211
  [docs/findings.md](docs/findings.md). Not yet: Windows, async commands
89
- (the run loop is single-threaded), native dialogs/tray/menus, multi-window,
212
+ that run in parallel (host code is single-threaded; `commandAsync` interleaves
213
+ instead), native dialogs/tray/menus, multi-window,
90
214
  icons/installers/notarization.
91
215
 
216
+ ## Releasing
217
+
218
+ Bump `version` in `package.json` and merge to `main`. The publish workflow
219
+ then does the rest: it compares the version against the registry, and if it
220
+ is new, scaffolds/builds/runs a smoke app on Linux, publishes to npm with
221
+ trusted publishing (OIDC — no token secret), tags the published commit
222
+ `v<version>`, and opens a GitHub release with generated notes.
223
+
224
+ A merge that does not change the version is a clean no-op: nothing is
225
+ published and no tag is created. If a publish ever succeeds but the tagging
226
+ step does not, npm and git are briefly out of step — reconcile with:
227
+
228
+ ```bash
229
+ git tag -a v<version> -m "janela <version>" <commit>
230
+ git push origin v<version>
231
+ gh release create v<version> --generate-notes --verify-tag
232
+ ```
233
+
92
234
  ## License
93
235
 
94
236
  MIT. Bundles [webview/webview](https://github.com/webview/webview) headers
package/bin/janela.mjs CHANGED
@@ -54,16 +54,95 @@ function loadConf(root) {
54
54
  return c;
55
55
  }
56
56
 
57
+ // ---- Windows toolchain ------------------------------------------------------
58
+
59
+ // scriptc's win32 lane is MinGW-shaped twice over: its runtime uses POSIX
60
+ // types the MSVC CRT lacks (ssize_t), and its event loop calls POSIX time
61
+ // APIs that only a full mingw-w64 provides ("the idle sleep is nanosleep
62
+ // (mingw-w64 ships it, over Sleep)" — scr_async.c). So Windows builds need a
63
+ // clang whose DEFAULT target is mingw: llvm-mingw, MSYS2's clang64, or
64
+ // WinLibs. A stock MSVC-targeting clang cannot compile scriptc's runtime, and
65
+ // zig's bundled mingw omits winpthreads, so it cannot either.
66
+ function winCcOrFail() {
67
+ const probe = spawnSync("clang", ["-dumpmachine"], { encoding: "utf8" });
68
+ if (probe.status !== 0) {
69
+ fail(
70
+ "Windows builds need a MinGW-targeting clang on PATH. Install llvm-mingw " +
71
+ "(https://github.com/mstorsjo/llvm-mingw/releases) or MSYS2's clang64 toolchain.",
72
+ );
73
+ }
74
+ const triple = probe.stdout.trim();
75
+ if (!/mingw|windows-gnu/i.test(triple)) {
76
+ fail(
77
+ `clang on PATH targets '${triple}', but scriptc's Windows runtime only builds with ` +
78
+ "MinGW (it uses ssize_t/nanosleep/clock_gettime, which the MSVC CRT lacks). " +
79
+ "Put a MinGW-targeting clang first on PATH — llvm-mingw " +
80
+ "(https://github.com/mstorsjo/llvm-mingw/releases) or MSYS2's clang64.",
81
+ );
82
+ }
83
+ return triple;
84
+ }
85
+
86
+ // ---- WebView2 SDK (Windows only) -------------------------------------------
87
+
88
+ // webview.h's Win32 backend includes <WebView2.h>, which ships in Microsoft's
89
+ // nuget package rather than the Windows SDK. Fetch it once into the build
90
+ // cache (a .nupkg is a zip) unless the caller points at their own copy.
91
+ const WEBVIEW2_VERSION = "1.0.2903.40";
92
+
93
+ function webview2Include(cacheDir) {
94
+ const override = process.env.JANELA_WEBVIEW2_INCLUDE;
95
+ if (override) {
96
+ if (!existsSync(join(override, "WebView2.h"))) {
97
+ fail(`JANELA_WEBVIEW2_INCLUDE=${override} does not contain WebView2.h`);
98
+ }
99
+ return override;
100
+ }
101
+
102
+ const sdkDir = join(cacheDir, `webview2-${WEBVIEW2_VERSION}`);
103
+ const incDir = join(sdkDir, "build", "native", "include");
104
+ if (existsSync(join(incDir, "WebView2.h"))) return incDir;
105
+
106
+ console.log(`janela: fetching WebView2 SDK ${WEBVIEW2_VERSION}`);
107
+ const zip = join(cacheDir, `webview2-${WEBVIEW2_VERSION}.zip`);
108
+ const url = `https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/${WEBVIEW2_VERSION}`;
109
+ run([
110
+ "powershell", "-NoProfile", "-NonInteractive", "-Command",
111
+ `$ErrorActionPreference='Stop';` +
112
+ `[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12;` +
113
+ `Invoke-WebRequest -Uri '${url}' -OutFile '${zip}';` +
114
+ `Expand-Archive -Path '${zip}' -DestinationPath '${sdkDir}' -Force`,
115
+ ]);
116
+ if (!existsSync(join(incDir, "WebView2.h"))) {
117
+ fail(`WebView2 SDK unpacked to ${sdkDir} but no build/native/include/WebView2.h`);
118
+ }
119
+ return incDir;
120
+ }
121
+
57
122
  // ---- shim ----------------------------------------------------------------
58
123
 
59
124
  function buildShim(cacheDir) {
60
125
  const src = join(KIT, "shim", "wvshim.cc");
61
- const obj = join(cacheDir, "wvshim.o");
62
- const lib = join(cacheDir, "libwvshim.a");
126
+ const win = process.platform === "win32";
127
+ // On Windows the object file is handed to the link directly: `ar` is not
128
+ // part of an MSVC toolchain, and a lone object needs no archive index.
129
+ const obj = join(cacheDir, win ? "wvshim.obj" : "wvshim.o");
130
+ const lib = win ? obj : join(cacheDir, "libwvshim.a");
63
131
  if (existsSync(lib) && statSync(lib).mtimeMs > statSync(src).mtimeMs) return lib;
64
132
 
65
133
  console.log("janela: compiling webview shim");
66
134
  const inc = `-I${join(KIT, "vendor-webview", "core", "include")}`;
135
+ if (win) {
136
+ console.log(`janela: building for ${winCcOrFail()}`);
137
+ run([
138
+ "clang++", "-c", src, "-o", obj, "-std=c++17", "-O2", inc,
139
+ // WebView2.h from the nuget SDK, plus mingw's missing EventToken.h.
140
+ `-I${webview2Include(cacheDir)}`,
141
+ `-I${join(KIT, "vendor-webview", "compatibility", "mingw", "include")}`,
142
+ "-DWIN32_LEAN_AND_MEAN", "-D_WIN32_WINNT=0x0601",
143
+ ]);
144
+ return lib;
145
+ }
67
146
  if (process.platform === "darwin") {
68
147
  run(["clang++", "-c", src, "-o", obj, "-std=c++17", "-O2", inc]);
69
148
  } else {
@@ -87,28 +166,80 @@ function ffiManifest(shimLib) {
87
166
  STR("wvInit", "wv_init"),
88
167
  STR("wvEval", "wv_eval"),
89
168
  STR("wvBind", "wv_bind"),
90
- { name: "wvReqLen", symbol: "wv_req_len", params: ["i32"], returns: "i32" },
91
- { name: "wvReqByte", symbol: "wv_req_byte", params: ["i32", "i32"], returns: "i32" },
92
- { name: "wvReplyReset", symbol: "wv_reply_reset", params: ["i32"], returns: "i32" },
93
- { name: "wvReplyPush", symbol: "wv_reply_push", params: ["i32", "i32"], returns: "i32" },
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.
94
173
  {
95
- name: "wvRun", symbol: "wv_run",
174
+ name: "wvOnInvoke", symbol: "wv_on_invoke",
96
175
  params: [
97
176
  "i32",
98
- { callback: { id: "run", params: ["u32", "u32", { context: "run" }], returns: "i32", lifetime: "call" } },
99
- { context: "run" },
177
+ { callback: { id: "inv", params: ["string", { context: "inv" }], returns: "i32", lifetime: "retained" } },
178
+ { context: "inv" },
100
179
  ],
101
180
  returns: "i32",
102
181
  },
182
+ {
183
+ name: "wvOnTick", symbol: "wv_on_tick",
184
+ params: [
185
+ "i32",
186
+ { callback: { id: "tick", params: [{ context: "tick" }], returns: "void", lifetime: "retained" } },
187
+ { context: "tick" },
188
+ ],
189
+ returns: "i32",
190
+ },
191
+ { name: "wvRun", symbol: "wv_run", params: ["i32"], returns: "i32" },
103
192
  { name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
193
+ // async: deferred returns + the UI-thread pump behind app.defer/sleep
194
+ { name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
195
+ { name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
196
+ { name: "wvTickStart", symbol: "wv_tick_start", params: ["i32", "i32"], returns: "i32" },
197
+ { name: "wvTickStop", symbol: "wv_tick_stop", params: ["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
+ { name: "wvFsStatus", symbol: "wv_fs_status", params: ["i32", "i32"], returns: "i32" },
202
+ {
203
+ name: "wvFsTake", symbol: "wv_fs_take",
204
+ params: [
205
+ "i32", "i32",
206
+ { callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
207
+ { context: "sink" },
208
+ ],
209
+ returns: "i32",
210
+ },
211
+ { name: "wvFsFree", symbol: "wv_fs_free", params: ["i32", "i32"], returns: "i32" },
104
212
  ];
105
213
 
214
+ if (process.platform === "win32") {
215
+ // MinGW ignores MSVC's #pragma comment(lib, ...), so the Win32 imports the
216
+ // WebView2 backend needs are named explicitly. scriptc's own win32 lane
217
+ // already adds advapi32/iphlpapi/ws2_32, so those are omitted here.
218
+ // `c++` pulls libc++ for the shim's std::string/exceptions.
219
+ //
220
+ // `pthread` (mingw's libwinpthread) is here to work around an upstream
221
+ // scriptc bug: its runtime calls clock_gettime/nanosleep, which mingw
222
+ // declares in <time.h> but implements in winpthreads, and scriptc's win32
223
+ // link never adds it. Without this the link dies with
224
+ // "undefined symbol: clock_gettime" — reproducible with a plain
225
+ // `scriptc build hello.ts` on Windows, no FFI involved.
226
+ return {
227
+ ffi_format: 4,
228
+ functions,
229
+ libraries: [shimLib],
230
+ system_libraries: [
231
+ "c++", "pthread",
232
+ "ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
233
+ ],
234
+ };
235
+ }
236
+
106
237
  if (process.platform === "darwin") {
107
238
  // scriptc has no -framework support, but `libraries` entries are passed to
108
239
  // the link as plain input files and ld64 accepts .tbd stubs.
109
240
  const sdk = capture(["xcrun", "--sdk", "macosx", "--show-sdk-path"]);
110
241
  return {
111
- ffi_format: 2,
242
+ ffi_format: 4,
112
243
  functions,
113
244
  libraries: [
114
245
  shimLib,
@@ -119,7 +250,7 @@ function ffiManifest(shimLib) {
119
250
  };
120
251
  }
121
252
  return {
122
- ffi_format: 2,
253
+ ffi_format: 4,
123
254
  functions,
124
255
  libraries: [shimLib],
125
256
  system_libraries: [
@@ -180,11 +311,15 @@ function build(root) {
180
311
  writeFileSync(join(buildDir, "janela.ffi.json"), JSON.stringify(ffiManifest(shimLib), null, 2) + "\n");
181
312
 
182
313
  console.log("janela: compiling TypeScript to a native binary");
183
- const bin = join(outDir, conf.name);
314
+ // An explicit --out is used verbatim, so the PE suffix is ours to add.
315
+ const bin = join(outDir, process.platform === "win32" ? `${conf.name}.exe` : conf.name);
316
+ // No SCRIPTC_CC/SCRIPTC_TARGET on Windows: scriptc's default driver is
317
+ // plain `clang`, which is exactly the MinGW-targeting clang checked above.
184
318
  run(["node", scriptcBin(), "build", "entry.ts", "--ffi", "janela.ffi.json", "-o", bin], { cwd: buildDir });
185
319
 
186
320
  // Symbol/debug metadata is ~16% of the binary and apps don't need it.
187
- // (On arm64 macOS, strip re-signs ad-hoc automatically.)
321
+ // (On arm64 macOS, strip re-signs ad-hoc automatically. MinGW keeps DWARF
322
+ // inside the .exe rather than a side-by-side .pdb, so Windows benefits too.)
188
323
  run(["strip", bin]);
189
324
 
190
325
  if (process.platform === "darwin") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Desktop apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  "jn": "bin/janela.mjs"
9
9
  },
10
10
  "dependencies": {
11
- "scriptc": "0.0.32"
11
+ "scriptc": "0.0.35"
12
12
  },
13
13
  "license": "MIT",
14
14
  "author": "Mateus Lage",
@@ -30,7 +30,7 @@
30
30
  "scriptc"
31
31
  ],
32
32
  "engines": {
33
- "node": ">=18"
33
+ "node": ">=24"
34
34
  },
35
35
  "files": [
36
36
  "bin/",