janela 0.1.2 → 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
@@ -69,10 +69,10 @@ Backend API (`src-host/main.ts`):
69
69
  import type { JanelaApp } from "./janela";
70
70
 
71
71
  export function setup(app: JanelaApp): void {
72
- app.command("add", (argsJson) => { // args in / result out as JSON text
73
- const a = JSON.parse(argsJson) as { a: number; b: number };
74
- app.emit("added", JSON.stringify(a.a + a.b)); // push an event to the page
75
- 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;
76
76
  });
77
77
  // app.quit() closes the window and returns from run()
78
78
  }
@@ -85,19 +85,19 @@ the window. Register it with `commandAsync` and answer whenever you are ready;
85
85
  the page keeps using the same `await janela.invoke(...)`.
86
86
 
87
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
88
+ app.commandAsync("wait", (args, resolve, reject) => {
89
+ const a = args as { ms: number };
90
+ app.sleep(a.ms, () => resolve("done")); // resolve later
91
91
  });
92
92
 
93
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 };
94
+ app.commandAsync("countTo", (args, resolve) => {
95
+ const a = args as { n: number };
96
96
  let i = 0;
97
97
  const step = (): void => {
98
98
  const end = Math.min(i + 2_000_000, a.n);
99
99
  for (; i < end; i++) { /* ... */ }
100
- if (i < a.n) app.defer(step); else resolve(JSON.stringify(i));
100
+ if (i < a.n) app.defer(step); else resolve(i);
101
101
  };
102
102
  app.defer(step);
103
103
  });
@@ -105,8 +105,36 @@ app.commandAsync("countTo", (argsJson, resolve) => {
105
105
 
106
106
  - `app.defer(fn)` — run `fn` on the next turn of the host loop.
107
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.
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.
110
138
 
111
139
  **Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
112
140
  as long as the program sits inside the `run()` FFI call, so `setTimeout`,
@@ -124,6 +152,33 @@ The backend is ordinary TypeScript with scriptc's stdlib — including a
124
152
  `node:fs` subset — so "read a file" or "call an API" is just code in a
125
153
  command handler, no plugin layer needed.
126
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
+
127
182
  ## What the CLI hides
128
183
 
129
184
  `janela build` assembles `.janela/build/` (runtime + your `main.ts` +
@@ -133,12 +188,12 @@ scriptc. On macOS the frameworks are linked as SDK `.tbd` stubs (scriptc has
133
188
  no `-framework` support) and the binary is wrapped into an ad-hoc-signed
134
189
  `.app` bundle.
135
190
 
136
- ## Constraints inherited from scriptc 0.0.32
191
+ ## Constraints inherited from scriptc
137
192
 
138
- - Command args/results cross the boundary as **JSON text** handlers
139
- `JSON.parse` in and `JSON.stringify` out. The runtime keeps the byte channel
140
- ASCII by `\uXXXX`-escaping non-ASCII (scriptc strings can't hold lone
141
- 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.
142
197
  - Never use a bare FFI call as a complete variable initializer or assignment
143
198
  RHS — it is silently miscompiled. Wrap it in any expression (`+ 0`). Plain
144
199
  TypeScript is unaffected; only the runtime does FFI, so app code rarely
package/bin/janela.mjs CHANGED
@@ -166,25 +166,49 @@ function ffiManifest(shimLib) {
166
166
  STR("wvInit", "wv_init"),
167
167
  STR("wvEval", "wv_eval"),
168
168
  STR("wvBind", "wv_bind"),
169
- { name: "wvReqLen", symbol: "wv_req_len", params: ["i32"], returns: "i32" },
170
- { name: "wvReqByte", symbol: "wv_req_byte", params: ["i32", "i32"], returns: "i32" },
171
- { name: "wvReplyReset", symbol: "wv_reply_reset", params: ["i32"], returns: "i32" },
172
- { 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.
173
173
  {
174
- name: "wvRun", symbol: "wv_run",
174
+ name: "wvOnInvoke", symbol: "wv_on_invoke",
175
175
  params: [
176
176
  "i32",
177
- { callback: { id: "run", params: ["u32", "u32", { context: "run" }], returns: "i32", lifetime: "call" } },
178
- { context: "run" },
177
+ { callback: { id: "inv", params: ["string", { context: "inv" }], returns: "i32", lifetime: "retained" } },
178
+ { context: "inv" },
179
179
  ],
180
180
  returns: "i32",
181
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" },
182
192
  { name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
183
193
  // async: deferred returns + the UI-thread pump behind app.defer/sleep
184
194
  { name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
185
195
  { name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
186
196
  { name: "wvTickStart", symbol: "wv_tick_start", params: ["i32", "i32"], returns: "i32" },
187
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" },
188
212
  ];
189
213
 
190
214
  if (process.platform === "win32") {
@@ -200,7 +224,7 @@ function ffiManifest(shimLib) {
200
224
  // "undefined symbol: clock_gettime" — reproducible with a plain
201
225
  // `scriptc build hello.ts` on Windows, no FFI involved.
202
226
  return {
203
- ffi_format: 2,
227
+ ffi_format: 4,
204
228
  functions,
205
229
  libraries: [shimLib],
206
230
  system_libraries: [
@@ -215,7 +239,7 @@ function ffiManifest(shimLib) {
215
239
  // the link as plain input files and ld64 accepts .tbd stubs.
216
240
  const sdk = capture(["xcrun", "--sdk", "macosx", "--show-sdk-path"]);
217
241
  return {
218
- ffi_format: 2,
242
+ ffi_format: 4,
219
243
  functions,
220
244
  libraries: [
221
245
  shimLib,
@@ -226,7 +250,7 @@ function ffiManifest(shimLib) {
226
250
  };
227
251
  }
228
252
  return {
229
- ffi_format: 2,
253
+ ffi_format: 4,
230
254
  functions,
231
255
  libraries: [shimLib],
232
256
  system_libraries: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.1.2",
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/",
package/runtime/janela.ts CHANGED
@@ -2,12 +2,13 @@
2
2
  //
3
3
  // The Tauri-shaped surface: one `__invoke` binding carries every command as a
4
4
  // (name, argsJson) envelope, dispatched to handlers registered on the app
5
- // object. Backend→frontend events ride wv_eval into the injected bootstrap.
5
+ // object. Handlers see decoded values the runtime owns JSON at the boundary.
6
+ // Backend→frontend events ride wv_eval into the injected bootstrap.
6
7
  //
7
8
  // NOTE ON STYLE: every FFI call whose result initializes a variable is written
8
- // `f(...) + 0`. scriptc 0.0.32 miscompiles a bare FFI call used as a complete
9
- // initializer/assignment RHS (see FINDINGS.md); any enclosing expression is
10
- // the workaround.
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.
11
12
 
12
13
  declare function wvCreate(debug: number): number;
13
14
  declare function wvSetTitle(h: number, title: string): number;
@@ -16,19 +17,23 @@ declare function wvSetHtml(h: number, html: string): number;
16
17
  declare function wvInit(h: number, js: string): number;
17
18
  declare function wvEval(h: number, js: string): number;
18
19
  declare function wvBind(h: number, name: string): number;
19
- declare function wvReqLen(h: number): number;
20
- declare function wvReqByte(h: number, i: number): number;
21
- declare function wvReplyReset(h: number): number;
22
- declare function wvReplyPush(h: number, b: number): number;
23
- declare function wvRun(h: number, cb: (bindIndex: number, seq: number) => number): number;
20
+ declare function wvReply(h: number, body: string): number;
21
+ declare function wvOnInvoke(h: number, cb: (req: string) => number): number;
22
+ declare function wvOnTick(h: number, cb: () => void): number;
23
+ declare function wvRun(h: number): number;
24
24
  declare function wvTerminate(h: number): number;
25
25
  declare function wvDefer(h: number): number;
26
26
  declare function wvResolve(h: number, id: number, status: number): number;
27
27
  declare function wvTickStart(h: number, intervalMs: number): number;
28
28
  declare function wvTickStop(h: number): number;
29
+ declare function wvFsRead(h: number, path: string): number;
30
+ declare function wvFsWrite(h: number, path: string, data: string): number;
31
+ declare function wvFsStatus(h: number, id: number): number;
32
+ declare function wvFsTake(h: number, id: number, sink: (text: string) => void): number;
33
+ declare function wvFsFree(h: number, id: number): number;
29
34
 
30
- // Bind index the shim uses for a timer tick rather than a page invoke.
31
- const TICK_BIND = 4294967295;
35
+ const FS_PENDING = 0;
36
+ const FS_OK = 1;
32
37
 
33
38
  // Injected into every page before it loads (webview_init).
34
39
  const BOOTSTRAP =
@@ -47,23 +52,35 @@ const BOOTSTRAP =
47
52
  " for (var i = 0; i < cbs.length; i++) cbs[i](payload);" +
48
53
  "};";
49
54
 
50
- // Handlers receive the invoke args as JSON text and must return JSON text
51
- // (what the frontend promise resolves with). Throwing is not supported by
52
- // scriptc across the FFI boundary return an error envelope instead.
53
- export type CommandHandler = (argsJson: string) => string;
55
+ // Handlers take the invoked arguments as a value and return a value; the
56
+ // runtime owns JSON at the boundary. `args` is whatever the page passed to
57
+ // janela.invoke(name, args) cast it to the shape you expect. The return
58
+ // value is what the page's promise resolves with.
59
+ //
60
+ // Throwing is not supported by scriptc across the FFI boundary. Use
61
+ // commandAsync's `reject` to fail a call, or return an error value.
62
+ export type CommandHandler = (args: unknown) => unknown;
54
63
 
55
64
  /**
56
65
  * An async command: return immediately, answer later. `resolve`/`reject` take
57
- * JSON text and settle the page's `await janela.invoke(...)` promise whenever
66
+ * a value and settle the page's `await janela.invoke(...)` promise whenever
58
67
  * they are called — from a later defer()/sleep() turn, or from another
59
68
  * command. The window stays responsive for as long as the call is pending.
60
69
  */
61
70
  export type AsyncCommandHandler = (
62
- argsJson: string,
63
- resolve: (json: string) => void,
64
- reject: (json: string) => void,
71
+ args: unknown,
72
+ resolve: (value: unknown) => void,
73
+ reject: (reason: unknown) => void,
65
74
  ) => void;
66
75
 
76
+ /**
77
+ * Completion of an async file operation. `err` is null on success; on failure
78
+ * it carries a Node-shaped message ("ENOENT: no such file or directory, open
79
+ * '/x'") and `text` is empty. Errors arrive as values, never as throws —
80
+ * scriptc cannot propagate an exception across the FFI boundary.
81
+ */
82
+ export type FsCallback = (err: string | null, text: string) => void;
83
+
67
84
  export interface WindowConfig {
68
85
  title: string;
69
86
  width: number;
@@ -83,85 +100,32 @@ export interface JanelaApp {
83
100
  /** Run fn after at least ms. The host loop's timer; scriptc's setTimeout
84
101
  * cannot fire while the window is open (its loop is parked inside run()). */
85
102
  sleep: (ms: number, fn: () => void) => void;
86
- /** Fire an event into the page; payloadJson must be valid JSON text. */
87
- emit: (event: string, payloadJson: string) => void;
103
+ /**
104
+ * Read a file without blocking the window. The syscall runs on a shim
105
+ * worker thread; the callback lands on the UI thread on a later turn.
106
+ * Prefer this over node:fs readFileSync inside a command — that one blocks
107
+ * the loop, and with it the whole window.
108
+ */
109
+ readFileAsync: (path: string, cb: FsCallback) => void;
110
+ /** Write a file without blocking the window; cb(null) on success. */
111
+ writeFileAsync: (
112
+ path: string,
113
+ data: string,
114
+ cb: (err: string | null) => void,
115
+ ) => void;
116
+ /** Fire an event into the page; the payload is delivered as a value. */
117
+ emit: (event: string, payload: unknown) => void;
88
118
  /** Close the window and make run() return. */
89
119
  quit: () => void;
90
120
  /** Show the page and block until the window closes. Returns the run status. */
91
121
  run: (html: string) => number;
92
122
  }
93
123
 
94
- const HEX = "0123456789abcdef";
95
-
96
- function uEscape(unit: number): string {
97
- return (
98
- "\\u" +
99
- HEX.charAt((unit >> 12) & 0xf) +
100
- HEX.charAt((unit >> 8) & 0xf) +
101
- HEX.charAt((unit >> 4) & 0xf) +
102
- HEX.charAt(unit & 0xf)
103
- );
104
- }
105
-
106
- // The request arrives as UTF-8 JSON bytes. scriptc strings cannot hold lone
107
- // surrogates (String.fromCharCode(0xd83d) yields a replacement char), so we
108
- // never build non-ASCII chars directly: every code point >= 0x80 is re-emitted
109
- // as a JSON \uXXXX escape (a surrogate PAIR of escapes for astral planes),
110
- // which JSON.parse reconstructs correctly. Legal only because the payload is
111
- // always JSON, where non-ASCII can only occur inside strings.
112
- function readRequest(h: number): string {
113
- let out = "";
114
- const n = wvReqLen(h) + 0;
115
- let i = 0;
116
- while (i < n) {
117
- const b0 = wvReqByte(h, i) + 0;
118
- i = i + 1;
119
- let cp = b0;
120
- if ((b0 & 0xe0) === 0xc0 && i < n) {
121
- cp = ((b0 & 0x1f) << 6) | (wvReqByte(h, i) & 0x3f);
122
- i = i + 1;
123
- } else if ((b0 & 0xf0) === 0xe0 && i + 1 < n) {
124
- cp = ((b0 & 0x0f) << 12) | ((wvReqByte(h, i) & 0x3f) << 6) | (wvReqByte(h, i + 1) & 0x3f);
125
- i = i + 2;
126
- } else if ((b0 & 0xf8) === 0xf0 && i + 2 < n) {
127
- cp =
128
- ((b0 & 0x07) << 18) |
129
- ((wvReqByte(h, i) & 0x3f) << 12) |
130
- ((wvReqByte(h, i + 1) & 0x3f) << 6) |
131
- (wvReqByte(h, i + 2) & 0x3f);
132
- i = i + 3;
133
- }
134
- if (cp < 0x80) {
135
- out = out + String.fromCharCode(cp);
136
- } else if (cp < 0x10000) {
137
- out = out + uEscape(cp);
138
- } else {
139
- const v = cp - 0x10000;
140
- out = out + uEscape(0xd800 + (v >> 10)) + uEscape(0xdc00 + (v & 0x3ff));
141
- }
142
- }
143
- return out;
144
- }
145
-
146
- // The reply must be valid JSON when it reaches the page. Non-ASCII chars can
147
- // only legally occur inside JSON strings, where a \uXXXX escape is always
148
- // equivalent — so escaping every char >127 keeps the byte channel ASCII-clean
149
- // (surrogate halves escape individually, which JSON also permits).
150
- function writeReply(h: number, body: string): void {
151
- wvReplyReset(h);
152
- for (let i = 0; i < body.length; i++) {
153
- const c = body.charCodeAt(i);
154
- if (c < 0x80) {
155
- wvReplyPush(h, c);
156
- } else {
157
- wvReplyPush(h, 92); // backslash
158
- wvReplyPush(h, 117); // 'u'
159
- wvReplyPush(h, HEX.charCodeAt((c >> 12) & 0xf));
160
- wvReplyPush(h, HEX.charCodeAt((c >> 8) & 0xf));
161
- wvReplyPush(h, HEX.charCodeAt((c >> 4) & 0xf));
162
- wvReplyPush(h, HEX.charCodeAt(c & 0xf));
163
- }
164
- }
124
+ // JSON.stringify yields undefined for undefined; the wire always needs a
125
+ // value, and a command that returns nothing should read as null in the page.
126
+ function encode(value: unknown): string {
127
+ if (value === undefined) return "null";
128
+ return JSON.stringify(value);
165
129
  }
166
130
 
167
131
  export function createApp(cfg: WindowConfig): JanelaApp {
@@ -173,14 +137,16 @@ export function createApp(cfg: WindowConfig): JanelaApp {
173
137
  // ---- the host loop -------------------------------------------------------
174
138
  // scriptc's event loop is parked for as long as the program sits inside the
175
139
  // 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.
140
+ // These queues are drained instead by the retained tick handler that the
141
+ // shim's ticker posts to the UI thread, and the ticker only runs while there
142
+ // is work — an idle app costs nothing.
179
143
  const asyncNames: string[] = [];
180
144
  const asyncHandlers: AsyncCommandHandler[] = [];
181
145
  let taskFns: (() => void)[] = [];
182
146
  let timerFns: (() => void)[] = [];
183
147
  let timerDue: number[] = [];
148
+ let fsIds: number[] = [];
149
+ let fsCbs: FsCallback[] = [];
184
150
  let ticking = false;
185
151
 
186
152
  const wake = (): void => {
@@ -191,7 +157,7 @@ export function createApp(cfg: WindowConfig): JanelaApp {
191
157
 
192
158
  const idle = (): void => {
193
159
  if (!ticking) return;
194
- if (taskFns.length > 0 || timerFns.length > 0) return;
160
+ if (taskFns.length > 0 || timerFns.length > 0 || fsIds.length > 0) return;
195
161
  ticking = false;
196
162
  wvTickStop(h);
197
163
  };
@@ -221,6 +187,44 @@ export function createApp(cfg: WindowConfig): JanelaApp {
221
187
  timerDue = keptDue;
222
188
  for (let i = 0; i < fire.length; i++) fire[i]();
223
189
  }
190
+
191
+ // Finished file jobs: the worker thread has already done the blocking
192
+ // syscall, so all that happens on this (UI) thread is the drain.
193
+ if (fsIds.length > 0) {
194
+ const keptIds: number[] = [];
195
+ const keptCbs: FsCallback[] = [];
196
+ const doneIds: number[] = [];
197
+ const doneCbs: FsCallback[] = [];
198
+ const doneOk: boolean[] = [];
199
+ for (let i = 0; i < fsIds.length; i++) {
200
+ const st = wvFsStatus(h, fsIds[i]) + 0;
201
+ if (st === FS_PENDING) {
202
+ keptIds.push(fsIds[i]);
203
+ keptCbs.push(fsCbs[i]);
204
+ } else {
205
+ doneIds.push(fsIds[i]);
206
+ doneCbs.push(fsCbs[i]);
207
+ doneOk.push(st === FS_OK);
208
+ }
209
+ }
210
+ fsIds = keptIds;
211
+ fsCbs = keptCbs;
212
+ for (let i = 0; i < doneIds.length; i++) {
213
+ // On failure the payload IS the error message, so one take serves both
214
+ // outcomes. The sink runs synchronously inside wvFsTake (the callback
215
+ // is lifetime:"call"), so `payload` is set by the time it returns.
216
+ let payload = "";
217
+ wvFsTake(h, doneIds[i], (text) => {
218
+ payload = text;
219
+ });
220
+ wvFsFree(h, doneIds[i]);
221
+ if (doneOk[i]) {
222
+ doneCbs[i](null, payload);
223
+ } else {
224
+ doneCbs[i](payload, "");
225
+ }
226
+ }
227
+ }
224
228
  idle();
225
229
  };
226
230
 
@@ -250,10 +254,34 @@ export function createApp(cfg: WindowConfig): JanelaApp {
250
254
  wake();
251
255
  },
252
256
 
253
- emit: (event, payloadJson) => {
257
+ readFileAsync: (path, cb) => {
258
+ const id = wvFsRead(h, path) + 0;
259
+ if (id < 0) {
260
+ app.defer(() => cb("EAGAIN: could not start a read of '" + path + "'", ""));
261
+ return;
262
+ }
263
+ fsIds.push(id);
264
+ fsCbs.push(cb);
265
+ wake();
266
+ },
267
+
268
+ writeFileAsync: (path, data, cb) => {
269
+ const id = wvFsWrite(h, path, data) + 0;
270
+ if (id < 0) {
271
+ app.defer(() => cb("EAGAIN: could not start a write of '" + path + "'"));
272
+ return;
273
+ }
274
+ fsIds.push(id);
275
+ // The write payload is empty on success; the shared callback shape just
276
+ // ignores the text argument.
277
+ fsCbs.push((err, _text) => cb(err));
278
+ wake();
279
+ },
280
+
281
+ emit: (event, payload) => {
254
282
  wvEval(
255
283
  h,
256
- "window.__wvEmit(" + JSON.stringify(event) + "," + payloadJson + ");",
284
+ "window.__wvEmit(" + JSON.stringify(event) + "," + encode(payload) + ");",
257
285
  );
258
286
  },
259
287
 
@@ -262,26 +290,16 @@ export function createApp(cfg: WindowConfig): JanelaApp {
262
290
  },
263
291
 
264
292
  run: (html) => {
265
- const INVOKE = wvBind(h, "__invoke") + 0;
266
- wvSetHtml(h, html);
267
-
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
- }
275
- if (bindIndex !== INVOKE) {
276
- writeReply(h, '"unknown binding"');
277
- return 1;
278
- }
279
- const env = JSON.parse(readRequest(h)) as string[];
293
+ // Both handlers are retained: registered once here, called by the shim
294
+ // for as long as the window is open.
295
+ wvOnTick(h, turn);
296
+ wvOnInvoke(h, (req) => {
297
+ const env = JSON.parse(req) as string[];
280
298
  const cmd = env[0];
281
- const argsJson = env[1];
299
+ const args = JSON.parse(env[1]) as unknown;
282
300
  for (let i = 0; i < app.names.length; i++) {
283
301
  if (app.names[i] === cmd) {
284
- writeReply(h, app.handlers[i](argsJson));
302
+ wvReply(h, encode(app.handlers[i](args)));
285
303
  return 0;
286
304
  }
287
305
  }
@@ -292,25 +310,29 @@ export function createApp(cfg: WindowConfig): JanelaApp {
292
310
  // that is. Meanwhile the loop is free to serve other calls.
293
311
  const id = wvDefer(h) + 0;
294
312
  if (id < 0) {
295
- writeReply(h, JSON.stringify("cannot defer command: " + cmd));
313
+ wvReply(h, encode("cannot defer command: " + cmd));
296
314
  return 1;
297
315
  }
298
- const settle = (status: number): ((json: string) => void) => {
316
+ const settle = (status: number): ((value: unknown) => void) => {
299
317
  let done = false;
300
- return (json: string) => {
318
+ return (value: unknown) => {
301
319
  if (done) return; // a promise settles once
302
320
  done = true;
303
- writeReply(h, json);
321
+ wvReply(h, encode(value));
304
322
  wvResolve(h, id, status);
305
323
  };
306
324
  };
307
- asyncHandlers[i](argsJson, settle(0), settle(1));
325
+ asyncHandlers[i](args, settle(0), settle(1));
308
326
  return 0;
309
327
  }
310
328
  }
311
- writeReply(h, JSON.stringify("unknown command: " + cmd));
329
+ wvReply(h, encode("unknown command: " + cmd));
312
330
  return 1; // rejects the frontend promise
313
- }) + 0;
331
+ });
332
+
333
+ wvBind(h, "__invoke");
334
+ wvSetHtml(h, html);
335
+ const rc = wvRun(h) + 0;
314
336
  return rc;
315
337
  },
316
338
  };
package/shim/wvshim.cc CHANGED
@@ -1,15 +1,17 @@
1
- // wvshim.cc — a C-ABI shim over webview.h shaped to scriptc's FFI format 2.
1
+ // wvshim.cc — a C-ABI shim over webview.h shaped to scriptc's FFI format 4.
2
2
  //
3
- // Three constraints drive every design decision here:
4
- // 1. scriptc has no pointer/u64 type, so webview_t can never cross the
5
- // boundary. We keep a handle table and hand out int32 indices.
6
- // 2. Callback params/returns are numeric scalars only, so the request JSON
7
- // cannot be passed as a callback argument. We stage it in a shim-side
8
- // buffer and let TS drain it byte-at-a-time via re-entrant FFI calls.
9
- // 3. Callbacks are lifetime:"call" only. webview_bind wants a retained
10
- // callback, which is not expressible. Instead the TS callback is handed
11
- // to wv_run(), which blocks for the whole app lifetime so "call scope"
12
- // and "app lifetime" coincide, and bind dispatch happens inside it.
3
+ // One constraint still drives the design: scriptc has no pointer/u64 type, so
4
+ // webview_t can never cross the boundary. We keep a handle table and hand out
5
+ // int32 indices.
6
+ //
7
+ // What the newer formats removed:
8
+ // * format 3 callback params may be `string`/`bytes`, so a payload crosses
9
+ // into TS as one argument instead of one FFI call per byte. Payloads going
10
+ // the other way ride `string` params on ordinary functions.
11
+ // * format 4 callbacks may be `retained`, so the invoke and tick handlers
12
+ // are registered once and live for the app's lifetime. wv_run() is a plain
13
+ // blocking call again; it no longer has to carry a callback whose "call
14
+ // scope" was standing in for "app lifetime".
13
15
 
14
16
  #include "webview.h"
15
17
 
@@ -17,6 +19,10 @@
17
19
  #include <chrono>
18
20
  #include <cstdint>
19
21
  #include <cstring>
22
+ #include <filesystem>
23
+ #include <fstream>
24
+ #include <memory>
25
+ #include <mutex>
20
26
  #include <string>
21
27
  #include <thread>
22
28
  #include <vector>
@@ -39,14 +45,17 @@ struct App {
39
45
  bool used = false;
40
46
  std::vector<Bind> binds;
41
47
 
42
- // Callback supplied by TS for the duration of wv_run().
43
- int32_t (*cb)(uint32_t, uint32_t, void *) = nullptr;
44
- void *cb_ctx = nullptr;
48
+ // Retained TS handlers, registered once and valid until the app exits. The
49
+ // request rides in as a (ptr, len) string param.
50
+ int32_t (*on_invoke)(const uint8_t *, size_t, void *) = nullptr;
51
+ void *on_invoke_ctx = nullptr;
52
+ void (*on_tick)(void *) = nullptr;
53
+ void *on_tick_ctx = nullptr;
45
54
 
46
55
  // Staging for the in-flight request.
47
56
  std::string req; // JSON args array from JS
48
57
  std::string cur_id; // webview's call id, needed by webview_return
49
- std::string reply; // response body assembled by TS
58
+ std::string reply; // response body handed over by TS in one call
50
59
  uint32_t seq = 0;
51
60
 
52
61
  // ---- async support ----
@@ -57,10 +66,6 @@ struct App {
57
66
  std::atomic<int32_t> tick_ms{16};
58
67
  };
59
68
 
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
-
64
69
  // Fixed-size table: handles are indices, never pointers.
65
70
  App g_apps[8];
66
71
 
@@ -74,14 +79,127 @@ std::string to_str(const uint8_t *p, size_t n) {
74
79
  return std::string(reinterpret_cast<const char *>(p), n);
75
80
  }
76
81
 
77
- // The single C trampoline registered with webview_bind for every binding.
78
- // `arg` encodes the app index and bind index.
82
+ // ---- file I/O jobs ---------------------------------------------------------
83
+ //
84
+ // The whole point of this subsystem is that the blocking syscall happens HERE,
85
+ // on a worker thread, and never on the UI thread. A worker touches only its
86
+ // own job and never calls into TS — scriptc's runtime is not thread-safe, so
87
+ // results cross back on the UI thread, drained by the tick loop.
88
+
89
+ const int32_t FS_PENDING = 0;
90
+ const int32_t FS_OK = 1;
91
+ const int32_t FS_ERROR = 2;
92
+
93
+ struct FsJob {
94
+ // Written by the worker before `status` flips; read by the UI thread only
95
+ // after it observes a terminal status. The release/acquire pair on `status`
96
+ // is what publishes `data`, so no lock is needed for the payload itself.
97
+ std::atomic<int32_t> status{FS_PENDING};
98
+ std::string data; // file contents on success, the error message on failure
99
+ std::thread worker;
100
+ bool used = false;
101
+ };
102
+
103
+ // Jobs are addressed by index and held behind unique_ptr so the vector may
104
+ // grow without invalidating a worker's pointer to its own job.
105
+ std::mutex g_fs_mu;
106
+ std::vector<std::unique_ptr<FsJob>> g_fs_jobs;
107
+
108
+ FsJob *fs_job_at(int32_t id) {
109
+ std::lock_guard<std::mutex> lock(g_fs_mu);
110
+ if (id < 0 || static_cast<size_t>(id) >= g_fs_jobs.size()) return nullptr;
111
+ FsJob *j = g_fs_jobs[id].get();
112
+ return j->used ? j : nullptr;
113
+ }
114
+
115
+ // Reuses a finished slot when one is free, so a long-running app that reads
116
+ // many files does not grow the table without bound.
117
+ int32_t fs_new_job() {
118
+ std::lock_guard<std::mutex> lock(g_fs_mu);
119
+ for (size_t i = 0; i < g_fs_jobs.size(); i++) {
120
+ if (g_fs_jobs[i]->used) continue;
121
+ if (g_fs_jobs[i]->worker.joinable()) g_fs_jobs[i]->worker.join();
122
+ g_fs_jobs[i]->status.store(FS_PENDING);
123
+ g_fs_jobs[i]->data.clear();
124
+ g_fs_jobs[i]->used = true;
125
+ return static_cast<int32_t>(i);
126
+ }
127
+ g_fs_jobs.push_back(std::unique_ptr<FsJob>(new FsJob()));
128
+ g_fs_jobs.back()->used = true;
129
+ return static_cast<int32_t>(g_fs_jobs.size() - 1);
130
+ }
131
+
132
+ // Node-shaped messages: janela apps already surface node:fs errors from
133
+ // synchronous handlers, so the async path should read the same.
134
+ std::string fs_error_message(const std::string &path, const char *op) {
135
+ std::error_code ec;
136
+ auto st = std::filesystem::status(path, ec);
137
+ if (st.type() == std::filesystem::file_type::not_found) {
138
+ return "ENOENT: no such file or directory, " + std::string(op) + " '" +
139
+ path + "'";
140
+ }
141
+ if (st.type() == std::filesystem::file_type::directory) {
142
+ return "EISDIR: illegal operation on a directory, " + std::string(op) +
143
+ " '" + path + "'";
144
+ }
145
+ return "EIO: failed to " + std::string(op) + " '" + path + "'";
146
+ }
147
+
148
+ void fs_finish(FsJob *j, int32_t status, std::string payload) {
149
+ j->data = std::move(payload);
150
+ j->status.store(status, std::memory_order_release);
151
+ }
152
+
153
+ void fs_read_worker(FsJob *j, std::string path) {
154
+ std::error_code ec;
155
+ if (std::filesystem::is_directory(path, ec)) {
156
+ fs_finish(j, FS_ERROR, fs_error_message(path, "read"));
157
+ return;
158
+ }
159
+ std::ifstream in(path, std::ios::binary);
160
+ if (!in) {
161
+ fs_finish(j, FS_ERROR, fs_error_message(path, "open"));
162
+ return;
163
+ }
164
+ std::string buf((std::istreambuf_iterator<char>(in)),
165
+ std::istreambuf_iterator<char>());
166
+ if (in.bad()) {
167
+ fs_finish(j, FS_ERROR, fs_error_message(path, "read"));
168
+ return;
169
+ }
170
+ fs_finish(j, FS_OK, std::move(buf));
171
+ }
172
+
173
+ void fs_write_worker(FsJob *j, std::string path, std::string data) {
174
+ std::ofstream out(path, std::ios::binary | std::ios::trunc);
175
+ if (!out) {
176
+ fs_finish(j, FS_ERROR, fs_error_message(path, "open"));
177
+ return;
178
+ }
179
+ out.write(data.data(), static_cast<std::streamsize>(data.size()));
180
+ out.flush();
181
+ if (!out) {
182
+ fs_finish(j, FS_ERROR, fs_error_message(path, "write"));
183
+ return;
184
+ }
185
+ fs_finish(j, FS_OK, std::string());
186
+ }
187
+
188
+ // Join every worker. Called at shutdown so no thread outlives the process's
189
+ // orderly exit (and so nothing writes into a job after main returns).
190
+ void fs_join_all() {
191
+ std::lock_guard<std::mutex> lock(g_fs_mu);
192
+ for (size_t i = 0; i < g_fs_jobs.size(); i++) {
193
+ if (g_fs_jobs[i]->worker.joinable()) g_fs_jobs[i]->worker.join();
194
+ }
195
+ }
196
+
197
+ // The single C trampoline registered with webview_bind. `arg` is the app
198
+ // index; every binding routes to the one retained invoke handler.
79
199
  void trampoline(const char *id, const char *req, void *arg) {
80
- uintptr_t packed = reinterpret_cast<uintptr_t>(arg);
81
- App *a = &g_apps[packed >> 32];
82
- uint32_t bind_index = static_cast<uint32_t>(packed & 0xffffffffu);
200
+ App *a = &g_apps[reinterpret_cast<uintptr_t>(arg)];
83
201
 
84
- if (!a->cb) return; // no TS handler installed
202
+ if (!a->on_invoke) return; // no TS handler installed
85
203
 
86
204
  a->req = req ? req : "";
87
205
  a->cur_id = id ? id : "";
@@ -89,9 +207,11 @@ void trampoline(const char *id, const char *req, void *arg) {
89
207
  a->deferred = false;
90
208
  a->seq++;
91
209
 
92
- // Re-entrancy: this call lands back in TS, which will call wv_req_byte()
93
- // etc. back into this shim before returning.
94
- int32_t status = a->cb(bind_index, a->seq, a->cb_ctx);
210
+ // Re-entrancy: this call lands back in TS, which calls wv_reply() back into
211
+ // this shim before returning.
212
+ int32_t status = a->on_invoke(
213
+ reinterpret_cast<const uint8_t *>(a->req.data()), a->req.size(),
214
+ a->on_invoke_ctx);
95
215
 
96
216
  // An async handler called wv_defer(): the call id now lives in the pending
97
217
  // table and wv_resolve() will answer the page later. Returning now would
@@ -114,9 +234,9 @@ void trampoline(const char *id, const char *req, void *arg) {
114
234
  // TS it calls stays single-threaded — scriptc's runtime is NOT thread-safe.
115
235
  void tick_on_ui_thread(webview_t, void *arg) {
116
236
  App *a = &g_apps[reinterpret_cast<uintptr_t>(arg)];
117
- if (!a->used || !a->cb) return; // app quit between dispatch and delivery
237
+ if (!a->used || !a->on_tick) return; // app quit between dispatch and delivery
118
238
  a->seq++;
119
- a->cb(TICK_BIND, a->seq, a->cb_ctx);
239
+ a->on_tick(a->on_tick_ctx);
120
240
  }
121
241
 
122
242
  } // namespace
@@ -131,8 +251,10 @@ int32_t wv_create(int32_t debug) {
131
251
  // Field-wise reset: App holds a thread and atomics, so it is not
132
252
  // copy-assignable from a temporary.
133
253
  g_apps[i].binds.clear();
134
- g_apps[i].cb = nullptr;
135
- g_apps[i].cb_ctx = nullptr;
254
+ g_apps[i].on_invoke = nullptr;
255
+ g_apps[i].on_invoke_ctx = nullptr;
256
+ g_apps[i].on_tick = nullptr;
257
+ g_apps[i].on_tick_ctx = nullptr;
136
258
  g_apps[i].req.clear();
137
259
  g_apps[i].cur_id.clear();
138
260
  g_apps[i].reply.clear();
@@ -189,40 +311,20 @@ int32_t wv_eval(int32_t h, const uint8_t *p, size_t n) {
189
311
  int32_t wv_bind(int32_t h, const uint8_t *p, size_t n) {
190
312
  App *a = app_at(h);
191
313
  if (!a) return -1;
192
- uint32_t idx = static_cast<uint32_t>(a->binds.size());
314
+ size_t idx = a->binds.size();
193
315
  a->binds.push_back(Bind{to_str(p, n)});
194
- uintptr_t packed = (static_cast<uintptr_t>(h) << 32) | idx;
195
316
  int rc = webview_bind(a->w, a->binds[idx].name.c_str(), trampoline,
196
- reinterpret_cast<void *>(packed));
317
+ reinterpret_cast<void *>(static_cast<uintptr_t>(h)));
197
318
  if (rc != WEBVIEW_ERROR_OK) return -1;
198
319
  return static_cast<int32_t>(idx);
199
320
  }
200
321
 
201
- // ---- request payload, drained byte-at-a-time from inside the TS callback ----
202
- int32_t wv_req_len(int32_t h) {
322
+ // Stage the response body for the call being handled (or, after wv_defer, for
323
+ // the pending call that wv_resolve will answer). One call, whole payload.
324
+ int32_t wv_reply(int32_t h, const uint8_t *p, size_t n) {
203
325
  App *a = app_at(h);
204
326
  if (!a) return -1;
205
- return static_cast<int32_t>(a->req.size());
206
- }
207
-
208
- int32_t wv_req_byte(int32_t h, int32_t i) {
209
- App *a = app_at(h);
210
- if (!a || i < 0 || static_cast<size_t>(i) >= a->req.size()) return -1;
211
- return static_cast<uint8_t>(a->req[i]);
212
- }
213
-
214
- // ---- reply payload, pushed byte-at-a-time from inside the TS callback ----
215
- int32_t wv_reply_reset(int32_t h) {
216
- App *a = app_at(h);
217
- if (!a) return -1;
218
- a->reply.clear();
219
- return 0;
220
- }
221
-
222
- int32_t wv_reply_push(int32_t h, int32_t byte) {
223
- App *a = app_at(h);
224
- if (!a) return -1;
225
- a->reply.push_back(static_cast<char>(byte & 0xff));
327
+ a->reply.assign(reinterpret_cast<const char *>(p), n);
226
328
  return 0;
227
329
  }
228
330
 
@@ -268,7 +370,7 @@ int32_t wv_resolve(int32_t h, int32_t id, int32_t status) {
268
370
  return 0;
269
371
  }
270
372
 
271
- // Start pumping TS with TICK_BIND callbacks every interval_ms. The thread
373
+ // Start pumping the retained tick handler every interval_ms. The thread
272
374
  // itself only sleeps and posts; all TS execution happens on the UI thread.
273
375
  int32_t wv_tick_start(int32_t h, int32_t interval_ms) {
274
376
  App *a = app_at(h);
@@ -295,19 +397,100 @@ int32_t wv_tick_stop(int32_t h) {
295
397
  return 0;
296
398
  }
297
399
 
298
- // Blocks for the app's lifetime; dispatches bind calls into `cb`.
299
- int32_t wv_run(int32_t h, int32_t (*cb)(uint32_t, uint32_t, void *),
300
- void *cb_ctx) {
400
+ // ---- async file I/O ---------------------------------------------------------
401
+ //
402
+ // wv_fs_read/wv_fs_write start a worker thread and return immediately with a
403
+ // job id. TS polls wv_fs_status() from its tick loop and drains the payload
404
+ // with wv_fs_byte() once the job is terminal. On failure the payload is the
405
+ // error message, so success and failure share one drain path.
406
+
407
+ int32_t wv_fs_read(int32_t h, const uint8_t *p, size_t n) {
408
+ if (!app_at(h)) return -1;
409
+ int32_t id = fs_new_job();
410
+ FsJob *j = fs_job_at(id);
411
+ if (!j) return -1;
412
+ j->worker = std::thread(fs_read_worker, j, to_str(p, n));
413
+ return id;
414
+ }
415
+
416
+ int32_t wv_fs_write(int32_t h, const uint8_t *p, size_t n, const uint8_t *dp,
417
+ size_t dn) {
418
+ if (!app_at(h)) return -1;
419
+ int32_t id = fs_new_job();
420
+ FsJob *j = fs_job_at(id);
421
+ if (!j) return -1;
422
+ j->worker = std::thread(fs_write_worker, j, to_str(p, n), to_str(dp, dn));
423
+ return id;
424
+ }
425
+
426
+ // 0 = still running, 1 = done, 2 = failed, -1 = no such job.
427
+ int32_t wv_fs_status(int32_t h, int32_t id) {
428
+ if (!app_at(h)) return -1;
429
+ FsJob *j = fs_job_at(id);
430
+ if (!j) return -1;
431
+ return j->status.load(std::memory_order_acquire);
432
+ }
433
+
434
+ // Hand a finished job's payload to TS in one call. The callback is
435
+ // lifetime:"call", so it runs synchronously here — on the UI thread, the only
436
+ // thread allowed to touch the scriptc runtime. The worker is already done by
437
+ // then (the caller has observed a terminal status), so `data` is stable.
438
+ int32_t wv_fs_take(int32_t h, int32_t id,
439
+ void (*sink)(const uint8_t *, size_t, void *), void *ctx) {
440
+ if (!app_at(h)) return -1;
441
+ FsJob *j = fs_job_at(id);
442
+ if (!j || !sink) return -1;
443
+ if (j->status.load(std::memory_order_acquire) == FS_PENDING) return -1;
444
+ sink(reinterpret_cast<const uint8_t *>(j->data.data()), j->data.size(), ctx);
445
+ return 0;
446
+ }
447
+
448
+ // Release the slot for reuse. Refuses while the worker is still running, so a
449
+ // job's buffer can never be recycled out from under its own thread.
450
+ int32_t wv_fs_free(int32_t h, int32_t id) {
451
+ if (!app_at(h)) return -1;
452
+ FsJob *j = fs_job_at(id);
453
+ if (!j) return -1;
454
+ if (j->status.load(std::memory_order_acquire) == FS_PENDING) return -1;
455
+ std::lock_guard<std::mutex> lock(g_fs_mu);
456
+ if (j->worker.joinable()) j->worker.join();
457
+ j->data.clear();
458
+ j->data.shrink_to_fit();
459
+ j->used = false;
460
+ return 0;
461
+ }
462
+
463
+ // Register the retained handler for page invokes. Valid until the app exits.
464
+ int32_t wv_on_invoke(int32_t h,
465
+ int32_t (*cb)(const uint8_t *, size_t, void *),
466
+ void *ctx) {
467
+ App *a = app_at(h);
468
+ if (!a) return -1;
469
+ a->on_invoke = cb;
470
+ a->on_invoke_ctx = ctx;
471
+ return 0;
472
+ }
473
+
474
+ // Register the retained handler the ticker pumps on the UI thread.
475
+ int32_t wv_on_tick(int32_t h, void (*cb)(void *), void *ctx) {
476
+ App *a = app_at(h);
477
+ if (!a) return -1;
478
+ a->on_tick = cb;
479
+ a->on_tick_ctx = ctx;
480
+ return 0;
481
+ }
482
+
483
+ // Blocks for the app's lifetime, dispatching into the retained handlers.
484
+ int32_t wv_run(int32_t h) {
301
485
  App *a = app_at(h);
302
486
  if (!a) return -1;
303
- a->cb = cb;
304
- a->cb_ctx = cb_ctx;
305
487
  int rc = webview_run(a->w);
306
- // The ticker must not outlive the callback it drives.
488
+ // Nothing may call into TS once run() has returned.
307
489
  a->ticking.store(false);
308
490
  if (a->ticker.joinable()) a->ticker.join();
309
- a->cb = nullptr; // callback must not outlive the call, per lifetime:"call"
310
- a->cb_ctx = nullptr;
491
+ fs_join_all(); // nor may an in-flight read outlive the app
492
+ a->on_invoke = nullptr;
493
+ a->on_tick = nullptr;
311
494
  return rc;
312
495
  }
313
496
 
@@ -17,6 +17,10 @@
17
17
  <button id="wait">wait 2s (async)</button>
18
18
  <button id="quit">Quit</button>
19
19
  </p>
20
+ <p>
21
+ <input id="path" type="text" value="janela.conf.json" size="32" />
22
+ <button id="read">read file (async)</button>
23
+ </p>
20
24
  <pre id="out">booting…</pre>
21
25
  <ul id="events"></ul>
22
26
 
@@ -51,6 +55,16 @@
51
55
  e.target.disabled = false;
52
56
  };
53
57
 
58
+ // Reads run on a worker thread in the shim, so even a large file
59
+ // does not freeze the window while it loads.
60
+ document.getElementById("read").onclick = async () => {
61
+ const path = document.getElementById("path").value;
62
+ const r = await janela.invoke("readFile", { path });
63
+ out.textContent = r.ok
64
+ ? `${path}: ${r.length} chars\n\n` + r.text.slice(0, 400)
65
+ : "error: " + r.error;
66
+ };
67
+
54
68
  document.getElementById("quit").onclick = () => janela.invoke("quit");
55
69
  };
56
70
  </script>
package/templates/main.ts CHANGED
@@ -1,46 +1,57 @@
1
1
  // src-host/main.ts — your app's backend, compiled to native code by scriptc.
2
2
  //
3
3
  // Register commands here; the page calls them with `await janela.invoke(name, args)`.
4
- // Handlers receive the args as JSON text and return JSON text (what the
5
- // frontend promise resolves with).
4
+ // Handlers take the arguments as a value and return a value the runtime owns
5
+ // JSON at the boundary, so there is no parsing or stringifying to do here.
6
6
  //
7
- // Gotcha inherited from scriptc 0.0.32: never use a bare FFI-backed call as a
7
+ // Gotcha inherited from scriptc: never use a bare FFI-backed call as a
8
8
  // complete variable initializer — wrap it in any expression (`+ 0`). Plain
9
9
  // TypeScript like everything in this file is unaffected.
10
10
 
11
11
  import type { JanelaApp } from "./janela";
12
12
 
13
13
  export function setup(app: JanelaApp): void {
14
- app.command("add", (argsJson) => {
15
- const a = JSON.parse(argsJson) as { a: number; b: number };
14
+ app.command("add", (args) => {
15
+ const a = args as { a: number; b: number };
16
16
  const sum = a.a + a.b;
17
17
  // Backend→frontend event, just to show the channel exists.
18
- app.emit("added", JSON.stringify(sum));
19
- return JSON.stringify(sum);
18
+ app.emit("added", sum);
19
+ return sum;
20
20
  });
21
21
 
22
- app.command("greet", (argsJson) => {
23
- const a = JSON.parse(argsJson) as { name: string };
24
- return JSON.stringify("Hello, " + a.name + " — from the native TS binary");
22
+ app.command("greet", (args) => {
23
+ const a = args as { name: string };
24
+ return "Hello, " + a.name + " — from the native TS binary";
25
25
  });
26
26
 
27
- app.command("log", (argsJson) => {
28
- const a = JSON.parse(argsJson) as string;
29
- console.log("[host] page says:", a);
30
- return "null";
27
+ app.command("log", (args) => {
28
+ console.log("[host] page says:", args as string);
31
29
  });
32
30
 
33
31
  // An async command: answers later, without freezing the window. The page
34
32
  // still just does `await janela.invoke("wait", { ms: 1000 })`.
35
- app.commandAsync("wait", (argsJson, resolve) => {
36
- const a = JSON.parse(argsJson) as { ms: number };
33
+ app.commandAsync("wait", (args, resolve) => {
34
+ const a = args as { ms: number };
37
35
  app.sleep(a.ms, () => {
38
- resolve(JSON.stringify("waited " + a.ms + "ms without blocking the UI"));
36
+ resolve("waited " + a.ms + "ms without blocking the UI");
39
37
  });
40
38
  });
41
39
 
42
- app.command("quit", (_argsJson) => {
40
+ // File I/O without freezing the window: the read happens on a worker thread
41
+ // in the shim. Use this instead of node:fs readFileSync, which blocks the
42
+ // host loop — and with it the whole UI — until the syscall returns.
43
+ app.commandAsync("readFile", (args, resolve) => {
44
+ const a = args as { path: string };
45
+ app.readFileAsync(a.path, (err, text) => {
46
+ if (err !== null) {
47
+ resolve({ ok: false, error: err });
48
+ return;
49
+ }
50
+ resolve({ ok: true, length: text.length, text: text });
51
+ });
52
+ });
53
+
54
+ app.command("quit", (_args) => {
43
55
  app.quit();
44
- return "null";
45
56
  });
46
57
  }