janela 0.1.0 → 0.1.2

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
 
@@ -53,6 +78,48 @@ export function setup(app: JanelaApp): void {
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", (argsJson, resolve, reject) => {
89
+ const a = JSON.parse(argsJson) as { ms: number };
90
+ app.sleep(a.ms, () => resolve(JSON.stringify("done"))); // resolve later
91
+ });
92
+
93
+ // Work that cannot just wait: slice it, yielding to the UI between slices.
94
+ app.commandAsync("countTo", (argsJson, resolve) => {
95
+ const a = JSON.parse(argsJson) 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(JSON.stringify(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(json)` / `reject(json)` settle the page's promise; `reject` makes
109
+ `await janela.invoke(...)` throw. Settling twice is ignored.
110
+
111
+ **Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
112
+ as long as the program sits inside the `run()` FFI call, so `setTimeout`,
113
+ `queueMicrotask` and `await` in host code never fire while the window is open
114
+ (they all run after it closes). janela supplies its own loop instead: a native
115
+ ticker posts work to the UI thread via `webview_dispatch`, and it only runs
116
+ while something is queued, so an idle app costs nothing.
117
+
118
+ **Still single-threaded.** scriptc's runtime is not thread-safe (concurrent
119
+ calls from several threads abort the process), so host code always runs on the
120
+ UI thread. Async here means *interleaved*, not parallel: a handler that blocks
121
+ without yielding still freezes the window. Slice long work with `defer`.
122
+
56
123
  The backend is ordinary TypeScript with scriptc's stdlib — including a
57
124
  `node:fs` subset — so "read a file" or "call an API" is just code in a
58
125
  command handler, no plugin layer needed.
@@ -76,8 +143,9 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
76
143
  RHS — it is silently miscompiled. Wrap it in any expression (`+ 0`). Plain
77
144
  TypeScript is unaffected; only the runtime does FFI, so app code rarely
78
145
  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).
146
+ - One window per app for now. Host code is single-threaded: a synchronous
147
+ command blocks the UI while it runs use `commandAsync` + `defer`/`sleep`
148
+ (see "Async commands") for anything slow.
81
149
  - `console.log` from commands goes to stdout — visible under `janela dev`,
82
150
  not when launched from Finder.
83
151
 
@@ -86,9 +154,28 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
86
154
  Early proof of concept, macOS (arm64) and Linux (WebKitGTK). The design
87
155
  notes and scriptc findings behind it are in
88
156
  [docs/findings.md](docs/findings.md). Not yet: Windows, async commands
89
- (the run loop is single-threaded), native dialogs/tray/menus, multi-window,
157
+ that run in parallel (host code is single-threaded; `commandAsync` interleaves
158
+ instead), native dialogs/tray/menus, multi-window,
90
159
  icons/installers/notarization.
91
160
 
161
+ ## Releasing
162
+
163
+ Bump `version` in `package.json` and merge to `main`. The publish workflow
164
+ then does the rest: it compares the version against the registry, and if it
165
+ is new, scaffolds/builds/runs a smoke app on Linux, publishes to npm with
166
+ trusted publishing (OIDC — no token secret), tags the published commit
167
+ `v<version>`, and opens a GitHub release with generated notes.
168
+
169
+ A merge that does not change the version is a clean no-op: nothing is
170
+ published and no tag is created. If a publish ever succeeds but the tagging
171
+ step does not, npm and git are briefly out of step — reconcile with:
172
+
173
+ ```bash
174
+ git tag -a v<version> -m "janela <version>" <commit>
175
+ git push origin v<version>
176
+ gh release create v<version> --generate-notes --verify-tag
177
+ ```
178
+
92
179
  ## License
93
180
 
94
181
  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 {
@@ -101,8 +180,36 @@ function ffiManifest(shimLib) {
101
180
  returns: "i32",
102
181
  },
103
182
  { name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
183
+ // async: deferred returns + the UI-thread pump behind app.defer/sleep
184
+ { name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
185
+ { name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
186
+ { name: "wvTickStart", symbol: "wv_tick_start", params: ["i32", "i32"], returns: "i32" },
187
+ { name: "wvTickStop", symbol: "wv_tick_stop", params: ["i32"], returns: "i32" },
104
188
  ];
105
189
 
190
+ if (process.platform === "win32") {
191
+ // MinGW ignores MSVC's #pragma comment(lib, ...), so the Win32 imports the
192
+ // WebView2 backend needs are named explicitly. scriptc's own win32 lane
193
+ // already adds advapi32/iphlpapi/ws2_32, so those are omitted here.
194
+ // `c++` pulls libc++ for the shim's std::string/exceptions.
195
+ //
196
+ // `pthread` (mingw's libwinpthread) is here to work around an upstream
197
+ // scriptc bug: its runtime calls clock_gettime/nanosleep, which mingw
198
+ // declares in <time.h> but implements in winpthreads, and scriptc's win32
199
+ // link never adds it. Without this the link dies with
200
+ // "undefined symbol: clock_gettime" — reproducible with a plain
201
+ // `scriptc build hello.ts` on Windows, no FFI involved.
202
+ return {
203
+ ffi_format: 2,
204
+ functions,
205
+ libraries: [shimLib],
206
+ system_libraries: [
207
+ "c++", "pthread",
208
+ "ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
209
+ ],
210
+ };
211
+ }
212
+
106
213
  if (process.platform === "darwin") {
107
214
  // scriptc has no -framework support, but `libraries` entries are passed to
108
215
  // the link as plain input files and ld64 accepts .tbd stubs.
@@ -180,9 +287,17 @@ function build(root) {
180
287
  writeFileSync(join(buildDir, "janela.ffi.json"), JSON.stringify(ffiManifest(shimLib), null, 2) + "\n");
181
288
 
182
289
  console.log("janela: compiling TypeScript to a native binary");
183
- const bin = join(outDir, conf.name);
290
+ // An explicit --out is used verbatim, so the PE suffix is ours to add.
291
+ const bin = join(outDir, process.platform === "win32" ? `${conf.name}.exe` : conf.name);
292
+ // No SCRIPTC_CC/SCRIPTC_TARGET on Windows: scriptc's default driver is
293
+ // plain `clang`, which is exactly the MinGW-targeting clang checked above.
184
294
  run(["node", scriptcBin(), "build", "entry.ts", "--ffi", "janela.ffi.json", "-o", bin], { cwd: buildDir });
185
295
 
296
+ // Symbol/debug metadata is ~16% of the binary and apps don't need it.
297
+ // (On arm64 macOS, strip re-signs ad-hoc automatically. MinGW keeps DWARF
298
+ // inside the .exe rather than a side-by-side .pdb, so Windows benefits too.)
299
+ run(["strip", bin]);
300
+
186
301
  if (process.platform === "darwin") {
187
302
  const bundle = join(outDir, `${conf.name}.app`);
188
303
  mkdirSync(join(bundle, "Contents", "MacOS"), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Desktop apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "type": "git",
17
17
  "url": "git+https://github.com/mmamedel/janela.git"
18
18
  },
19
- "homepage": "https://github.com/mmamedel/janela#readme",
19
+ "homepage": "https://mmamedel.github.io/janela/",
20
20
  "bugs": {
21
21
  "url": "https://github.com/mmamedel/janela/issues"
22
22
  },
package/runtime/janela.ts CHANGED
@@ -22,6 +22,13 @@ declare function wvReplyReset(h: number): number;
22
22
  declare function wvReplyPush(h: number, b: number): number;
23
23
  declare function wvRun(h: number, cb: (bindIndex: number, seq: number) => number): number;
24
24
  declare function wvTerminate(h: number): number;
25
+ declare function wvDefer(h: number): number;
26
+ declare function wvResolve(h: number, id: number, status: number): number;
27
+ declare function wvTickStart(h: number, intervalMs: number): number;
28
+ declare function wvTickStop(h: number): number;
29
+
30
+ // Bind index the shim uses for a timer tick rather than a page invoke.
31
+ const TICK_BIND = 4294967295;
25
32
 
26
33
  // Injected into every page before it loads (webview_init).
27
34
  const BOOTSTRAP =
@@ -45,6 +52,18 @@ const BOOTSTRAP =
45
52
  // scriptc across the FFI boundary — return an error envelope instead.
46
53
  export type CommandHandler = (argsJson: string) => string;
47
54
 
55
+ /**
56
+ * An async command: return immediately, answer later. `resolve`/`reject` take
57
+ * JSON text and settle the page's `await janela.invoke(...)` promise whenever
58
+ * they are called — from a later defer()/sleep() turn, or from another
59
+ * command. The window stays responsive for as long as the call is pending.
60
+ */
61
+ export type AsyncCommandHandler = (
62
+ argsJson: string,
63
+ resolve: (json: string) => void,
64
+ reject: (json: string) => void,
65
+ ) => void;
66
+
48
67
  export interface WindowConfig {
49
68
  title: string;
50
69
  width: number;
@@ -57,6 +76,13 @@ export interface JanelaApp {
57
76
  handlers: CommandHandler[];
58
77
  /** Register a named command, callable from the page as janela.invoke(name, args). */
59
78
  command: (name: string, h: CommandHandler) => void;
79
+ /** Register a command that answers later; see AsyncCommandHandler. */
80
+ commandAsync: (name: string, h: AsyncCommandHandler) => void;
81
+ /** Run fn on the next turn of the host loop — the way to slice long work. */
82
+ defer: (fn: () => void) => void;
83
+ /** Run fn after at least ms. The host loop's timer; scriptc's setTimeout
84
+ * cannot fire while the window is open (its loop is parked inside run()). */
85
+ sleep: (ms: number, fn: () => void) => void;
60
86
  /** Fire an event into the page; payloadJson must be valid JSON text. */
61
87
  emit: (event: string, payloadJson: string) => void;
62
88
  /** Close the window and make run() return. */
@@ -144,6 +170,60 @@ export function createApp(cfg: WindowConfig): JanelaApp {
144
170
  wvSetSize(h, cfg.width, cfg.height, 0);
145
171
  wvInit(h, BOOTSTRAP);
146
172
 
173
+ // ---- the host loop -------------------------------------------------------
174
+ // scriptc's event loop is parked for as long as the program sits inside the
175
+ // wvRun() FFI call, so setTimeout/await never fire while the window is open.
176
+ // These queues are drained instead by TICK_BIND callbacks that the shim's
177
+ // ticker posts to the UI thread, and the ticker only runs while there is
178
+ // work — an idle app costs nothing.
179
+ const asyncNames: string[] = [];
180
+ const asyncHandlers: AsyncCommandHandler[] = [];
181
+ let taskFns: (() => void)[] = [];
182
+ let timerFns: (() => void)[] = [];
183
+ let timerDue: number[] = [];
184
+ let ticking = false;
185
+
186
+ const wake = (): void => {
187
+ if (ticking) return;
188
+ ticking = true;
189
+ wvTickStart(h, 8);
190
+ };
191
+
192
+ const idle = (): void => {
193
+ if (!ticking) return;
194
+ if (taskFns.length > 0 || timerFns.length > 0) return;
195
+ ticking = false;
196
+ wvTickStop(h);
197
+ };
198
+
199
+ // One turn of the loop: every task queued so far, plus every due timer.
200
+ // Tasks queued *by* this turn wait for the next one, so a defer() chain
201
+ // yields to the UI between slices instead of starving it.
202
+ const turn = (): void => {
203
+ const tasks = taskFns;
204
+ taskFns = [];
205
+ for (let i = 0; i < tasks.length; i++) tasks[i]();
206
+
207
+ if (timerFns.length > 0) {
208
+ const now = Date.now() + 0;
209
+ const keptFns: (() => void)[] = [];
210
+ const keptDue: number[] = [];
211
+ const fire: (() => void)[] = [];
212
+ for (let i = 0; i < timerFns.length; i++) {
213
+ if (timerDue[i] <= now) {
214
+ fire.push(timerFns[i]);
215
+ } else {
216
+ keptFns.push(timerFns[i]);
217
+ keptDue.push(timerDue[i]);
218
+ }
219
+ }
220
+ timerFns = keptFns;
221
+ timerDue = keptDue;
222
+ for (let i = 0; i < fire.length; i++) fire[i]();
223
+ }
224
+ idle();
225
+ };
226
+
147
227
  const app: JanelaApp = {
148
228
  handle: h,
149
229
  names: [],
@@ -154,6 +234,22 @@ export function createApp(cfg: WindowConfig): JanelaApp {
154
234
  app.handlers.push(handler);
155
235
  },
156
236
 
237
+ commandAsync: (name, handler) => {
238
+ asyncNames.push(name);
239
+ asyncHandlers.push(handler);
240
+ },
241
+
242
+ defer: (fn) => {
243
+ taskFns.push(fn);
244
+ wake();
245
+ },
246
+
247
+ sleep: (ms, fn) => {
248
+ timerFns.push(fn);
249
+ timerDue.push(Date.now() + (ms > 0 ? ms : 0));
250
+ wake();
251
+ },
252
+
157
253
  emit: (event, payloadJson) => {
158
254
  wvEval(
159
255
  h,
@@ -170,6 +266,12 @@ export function createApp(cfg: WindowConfig): JanelaApp {
170
266
  wvSetHtml(h, html);
171
267
 
172
268
  const rc = wvRun(h, (bindIndex, _seq) => {
269
+ // A tick is not an invoke: nothing is waiting on a reply, so the
270
+ // shim never calls webview_return for it.
271
+ if (bindIndex === TICK_BIND) {
272
+ turn();
273
+ return 0;
274
+ }
173
275
  if (bindIndex !== INVOKE) {
174
276
  writeReply(h, '"unknown binding"');
175
277
  return 1;
@@ -183,6 +285,29 @@ export function createApp(cfg: WindowConfig): JanelaApp {
183
285
  return 0;
184
286
  }
185
287
  }
288
+ for (let i = 0; i < asyncNames.length; i++) {
289
+ if (asyncNames[i] === cmd) {
290
+ // Park the page's promise: the shim holds this call's id and
291
+ // answers it when resolve/reject reaches wvResolve, whenever
292
+ // that is. Meanwhile the loop is free to serve other calls.
293
+ const id = wvDefer(h) + 0;
294
+ if (id < 0) {
295
+ writeReply(h, JSON.stringify("cannot defer command: " + cmd));
296
+ return 1;
297
+ }
298
+ const settle = (status: number): ((json: string) => void) => {
299
+ let done = false;
300
+ return (json: string) => {
301
+ if (done) return; // a promise settles once
302
+ done = true;
303
+ writeReply(h, json);
304
+ wvResolve(h, id, status);
305
+ };
306
+ };
307
+ asyncHandlers[i](argsJson, settle(0), settle(1));
308
+ return 0;
309
+ }
310
+ }
186
311
  writeReply(h, JSON.stringify("unknown command: " + cmd));
187
312
  return 1; // rejects the frontend promise
188
313
  }) + 0;
package/shim/wvshim.cc CHANGED
@@ -13,9 +13,12 @@
13
13
 
14
14
  #include "webview.h"
15
15
 
16
+ #include <atomic>
17
+ #include <chrono>
16
18
  #include <cstdint>
17
19
  #include <cstring>
18
20
  #include <string>
21
+ #include <thread>
19
22
  #include <vector>
20
23
 
21
24
  namespace {
@@ -24,6 +27,13 @@ struct Bind {
24
27
  std::string name;
25
28
  };
26
29
 
30
+ // An invoke whose webview_return was postponed by wv_defer(): the page's
31
+ // promise stays unsettled until TS calls wv_resolve() on a later turn.
32
+ struct Pending {
33
+ bool used = false;
34
+ std::string call_id;
35
+ };
36
+
27
37
  struct App {
28
38
  webview_t w = nullptr;
29
39
  bool used = false;
@@ -38,8 +48,19 @@ struct App {
38
48
  std::string cur_id; // webview's call id, needed by webview_return
39
49
  std::string reply; // response body assembled by TS
40
50
  uint32_t seq = 0;
51
+
52
+ // ---- async support ----
53
+ std::vector<Pending> pending; // deferred invokes, addressed by index
54
+ bool deferred = false; // set by wv_defer() during the current call
55
+ std::thread ticker; // pure-C++ thread; never touches TS itself
56
+ std::atomic<bool> ticking{false};
57
+ std::atomic<int32_t> tick_ms{16};
41
58
  };
42
59
 
60
+ // Bind index handed to the TS callback for a timer tick rather than an
61
+ // invoke. Real bind indices are small sequential integers.
62
+ const uint32_t TICK_BIND = 0xffffffffu;
63
+
43
64
  // Fixed-size table: handles are indices, never pointers.
44
65
  App g_apps[8];
45
66
 
@@ -65,14 +86,37 @@ void trampoline(const char *id, const char *req, void *arg) {
65
86
  a->req = req ? req : "";
66
87
  a->cur_id = id ? id : "";
67
88
  a->reply.clear();
89
+ a->deferred = false;
68
90
  a->seq++;
69
91
 
70
92
  // Re-entrancy: this call lands back in TS, which will call wv_req_byte()
71
93
  // etc. back into this shim before returning.
72
94
  int32_t status = a->cb(bind_index, a->seq, a->cb_ctx);
73
95
 
96
+ // An async handler called wv_defer(): the call id now lives in the pending
97
+ // table and wv_resolve() will answer the page later. Returning now would
98
+ // settle the promise with a stale value.
99
+ if (a->deferred) {
100
+ a->deferred = false;
101
+ a->cur_id.clear();
102
+ return;
103
+ }
104
+
74
105
  webview_return(a->w, a->cur_id.c_str(), status,
75
106
  a->reply.empty() ? "null" : a->reply.c_str());
107
+ // wv_defer() treats a non-empty cur_id as "an invoke is in flight". Clearing
108
+ // it here means a defer from anywhere else — a tick, say — fails with -1
109
+ // instead of stealing this already-answered call's id.
110
+ a->cur_id.clear();
111
+ }
112
+
113
+ // Runs on the UI thread (posted by the ticker via webview_dispatch), so the
114
+ // TS it calls stays single-threaded — scriptc's runtime is NOT thread-safe.
115
+ void tick_on_ui_thread(webview_t, void *arg) {
116
+ App *a = &g_apps[reinterpret_cast<uintptr_t>(arg)];
117
+ if (!a->used || !a->cb) return; // app quit between dispatch and delivery
118
+ a->seq++;
119
+ a->cb(TICK_BIND, a->seq, a->cb_ctx);
76
120
  }
77
121
 
78
122
  } // namespace
@@ -84,7 +128,19 @@ int32_t wv_create(int32_t debug) {
84
128
  if (g_apps[i].used) continue;
85
129
  webview_t w = webview_create(debug, nullptr);
86
130
  if (!w) return -1;
87
- g_apps[i] = App{};
131
+ // Field-wise reset: App holds a thread and atomics, so it is not
132
+ // copy-assignable from a temporary.
133
+ g_apps[i].binds.clear();
134
+ g_apps[i].cb = nullptr;
135
+ g_apps[i].cb_ctx = nullptr;
136
+ g_apps[i].req.clear();
137
+ g_apps[i].cur_id.clear();
138
+ g_apps[i].reply.clear();
139
+ g_apps[i].seq = 0;
140
+ g_apps[i].pending.clear();
141
+ g_apps[i].deferred = false;
142
+ g_apps[i].ticking.store(false);
143
+ g_apps[i].tick_ms.store(16);
88
144
  g_apps[i].w = w;
89
145
  g_apps[i].used = true;
90
146
  return i;
@@ -170,6 +226,75 @@ int32_t wv_reply_push(int32_t h, int32_t byte) {
170
226
  return 0;
171
227
  }
172
228
 
229
+ // ---- async: deferred returns + a UI-thread pump -----------------------------
230
+ //
231
+ // scriptc's own event loop does not run while the program sits inside an FFI
232
+ // call, and wv_run() is one such call for the app's whole life — so setTimeout
233
+ // and promise continuations in TS never fire while the window is open. These
234
+ // four functions supply the missing loop: TS may postpone an invoke's answer
235
+ // (wv_defer), answer it later (wv_resolve), and get called back periodically
236
+ // on the UI thread to make progress (wv_tick_start / wv_tick_stop).
237
+
238
+ // Postpone the answer to the invoke being handled right now. Returns a
239
+ // pending id to hand back to wv_resolve(), or -1 outside a bind callback.
240
+ int32_t wv_defer(int32_t h) {
241
+ App *a = app_at(h);
242
+ if (!a || a->cur_id.empty()) return -1;
243
+ for (size_t i = 0; i < a->pending.size(); i++) {
244
+ if (!a->pending[i].used) {
245
+ a->pending[i].used = true;
246
+ a->pending[i].call_id = a->cur_id;
247
+ a->deferred = true;
248
+ return static_cast<int32_t>(i);
249
+ }
250
+ }
251
+ a->pending.push_back(Pending{true, a->cur_id});
252
+ a->deferred = true;
253
+ return static_cast<int32_t>(a->pending.size() - 1);
254
+ }
255
+
256
+ // Answer a deferred invoke with whatever TS has staged via wv_reply_push().
257
+ // status 0 resolves the page's promise, non-zero rejects it.
258
+ int32_t wv_resolve(int32_t h, int32_t id, int32_t status) {
259
+ App *a = app_at(h);
260
+ if (!a || id < 0 || static_cast<size_t>(id) >= a->pending.size()) return -1;
261
+ Pending &p = a->pending[id];
262
+ if (!p.used) return -1;
263
+ webview_return(a->w, p.call_id.c_str(), status,
264
+ a->reply.empty() ? "null" : a->reply.c_str());
265
+ p.used = false;
266
+ p.call_id.clear();
267
+ a->reply.clear();
268
+ return 0;
269
+ }
270
+
271
+ // Start pumping TS with TICK_BIND callbacks every interval_ms. The thread
272
+ // itself only sleeps and posts; all TS execution happens on the UI thread.
273
+ int32_t wv_tick_start(int32_t h, int32_t interval_ms) {
274
+ App *a = app_at(h);
275
+ if (!a) return -1;
276
+ a->tick_ms = interval_ms > 0 ? interval_ms : 16;
277
+ if (a->ticking.exchange(true)) return 0; // already running
278
+ uintptr_t idx = static_cast<uintptr_t>(h);
279
+ a->ticker = std::thread([a, idx]() {
280
+ while (a->ticking.load()) {
281
+ std::this_thread::sleep_for(
282
+ std::chrono::milliseconds(a->tick_ms.load()));
283
+ if (!a->ticking.load()) break;
284
+ webview_dispatch(a->w, tick_on_ui_thread, reinterpret_cast<void *>(idx));
285
+ }
286
+ });
287
+ return 0;
288
+ }
289
+
290
+ int32_t wv_tick_stop(int32_t h) {
291
+ App *a = app_at(h);
292
+ if (!a) return -1;
293
+ if (!a->ticking.exchange(false)) return 0;
294
+ if (a->ticker.joinable()) a->ticker.join();
295
+ return 0;
296
+ }
297
+
173
298
  // Blocks for the app's lifetime; dispatches bind calls into `cb`.
174
299
  int32_t wv_run(int32_t h, int32_t (*cb)(uint32_t, uint32_t, void *),
175
300
  void *cb_ctx) {
@@ -178,6 +303,9 @@ int32_t wv_run(int32_t h, int32_t (*cb)(uint32_t, uint32_t, void *),
178
303
  a->cb = cb;
179
304
  a->cb_ctx = cb_ctx;
180
305
  int rc = webview_run(a->w);
306
+ // The ticker must not outlive the callback it drives.
307
+ a->ticking.store(false);
308
+ if (a->ticker.joinable()) a->ticker.join();
181
309
  a->cb = nullptr; // callback must not outlive the call, per lifetime:"call"
182
310
  a->cb_ctx = nullptr;
183
311
  return rc;
@@ -192,6 +320,8 @@ int32_t wv_terminate(int32_t h) {
192
320
  int32_t wv_destroy(int32_t h) {
193
321
  App *a = app_at(h);
194
322
  if (!a) return -1;
323
+ a->ticking.store(false);
324
+ if (a->ticker.joinable()) a->ticker.join();
195
325
  webview_destroy(a->w);
196
326
  a->used = false;
197
327
  a->w = nullptr;
@@ -14,6 +14,7 @@
14
14
  <input id="a" type="number" value="2" /> +
15
15
  <input id="b" type="number" value="40" />
16
16
  <button id="add">add</button>
17
+ <button id="wait">wait 2s (async)</button>
17
18
  <button id="quit">Quit</button>
18
19
  </p>
19
20
  <pre id="out">booting…</pre>
@@ -41,6 +42,15 @@
41
42
  const sum = await janela.invoke("add", { a, b });
42
43
  out.textContent = `add(${a}, ${b}) -> ${sum}`;
43
44
  };
45
+ // The window stays responsive while this is pending: the spinner keeps
46
+ // animating and "add" still works.
47
+ document.getElementById("wait").onclick = async (e) => {
48
+ e.target.disabled = true;
49
+ out.textContent = "waiting… (try 'add' — it still answers)";
50
+ out.textContent = await janela.invoke("wait", { ms: 2000 });
51
+ e.target.disabled = false;
52
+ };
53
+
44
54
  document.getElementById("quit").onclick = () => janela.invoke("quit");
45
55
  };
46
56
  </script>
package/templates/main.ts CHANGED
@@ -30,6 +30,15 @@ export function setup(app: JanelaApp): void {
30
30
  return "null";
31
31
  });
32
32
 
33
+ // An async command: answers later, without freezing the window. The page
34
+ // still just does `await janela.invoke("wait", { ms: 1000 })`.
35
+ app.commandAsync("wait", (argsJson, resolve) => {
36
+ const a = JSON.parse(argsJson) as { ms: number };
37
+ app.sleep(a.ms, () => {
38
+ resolve(JSON.stringify("waited " + a.ms + "ms without blocking the UI"));
39
+ });
40
+ });
41
+
33
42
  app.command("quit", (_argsJson) => {
34
43
  app.quit();
35
44
  return "null";