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 +72 -17
- package/bin/janela.mjs +34 -10
- package/package.json +3 -3
- package/runtime/janela.ts +144 -122
- package/shim/wvshim.cc +250 -67
- package/templates/index.html +14 -0
- package/templates/main.ts +30 -19
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", (
|
|
73
|
-
const a =
|
|
74
|
-
app.emit("added",
|
|
75
|
-
return
|
|
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", (
|
|
89
|
-
const a =
|
|
90
|
-
app.sleep(a.ms, () => resolve(
|
|
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", (
|
|
95
|
-
const a =
|
|
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(
|
|
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(
|
|
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
|
|
191
|
+
## Constraints inherited from scriptc
|
|
137
192
|
|
|
138
|
-
- Command args
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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: "
|
|
174
|
+
name: "wvOnInvoke", symbol: "wv_on_invoke",
|
|
175
175
|
params: [
|
|
176
176
|
"i32",
|
|
177
|
-
{ callback: { id: "
|
|
178
|
-
{ context: "
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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": ">=
|
|
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.
|
|
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
|
|
9
|
-
// initializer/assignment RHS
|
|
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
|
|
20
|
-
declare function
|
|
21
|
-
declare function
|
|
22
|
-
declare function
|
|
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
|
-
|
|
31
|
-
const
|
|
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
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
63
|
-
resolve: (
|
|
64
|
-
reject: (
|
|
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
|
-
/**
|
|
87
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
function
|
|
97
|
-
return
|
|
98
|
-
|
|
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
|
|
177
|
-
// ticker posts to the UI thread, and the ticker only runs while there
|
|
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
|
-
|
|
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) + "," +
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
313
|
+
wvReply(h, encode("cannot defer command: " + cmd));
|
|
296
314
|
return 1;
|
|
297
315
|
}
|
|
298
|
-
const settle = (status: number): ((
|
|
316
|
+
const settle = (status: number): ((value: unknown) => void) => {
|
|
299
317
|
let done = false;
|
|
300
|
-
return (
|
|
318
|
+
return (value: unknown) => {
|
|
301
319
|
if (done) return; // a promise settles once
|
|
302
320
|
done = true;
|
|
303
|
-
|
|
321
|
+
wvReply(h, encode(value));
|
|
304
322
|
wvResolve(h, id, status);
|
|
305
323
|
};
|
|
306
324
|
};
|
|
307
|
-
asyncHandlers[i](
|
|
325
|
+
asyncHandlers[i](args, settle(0), settle(1));
|
|
308
326
|
return 0;
|
|
309
327
|
}
|
|
310
328
|
}
|
|
311
|
-
|
|
329
|
+
wvReply(h, encode("unknown command: " + cmd));
|
|
312
330
|
return 1; // rejects the frontend promise
|
|
313
|
-
})
|
|
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
|
|
1
|
+
// wvshim.cc — a C-ABI shim over webview.h shaped to scriptc's FFI format 4.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
43
|
-
|
|
44
|
-
void *
|
|
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
|
|
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
|
-
//
|
|
78
|
-
//
|
|
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
|
-
|
|
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->
|
|
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
|
|
93
|
-
//
|
|
94
|
-
int32_t status = a->
|
|
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->
|
|
237
|
+
if (!a->used || !a->on_tick) return; // app quit between dispatch and delivery
|
|
118
238
|
a->seq++;
|
|
119
|
-
a->
|
|
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].
|
|
135
|
-
g_apps[i].
|
|
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
|
-
|
|
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 *>(
|
|
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
|
-
//
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
310
|
-
a->
|
|
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
|
|
package/templates/index.html
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
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
|
|
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", (
|
|
15
|
-
const a =
|
|
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",
|
|
19
|
-
return
|
|
18
|
+
app.emit("added", sum);
|
|
19
|
+
return sum;
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
app.command("greet", (
|
|
23
|
-
const a =
|
|
24
|
-
return
|
|
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", (
|
|
28
|
-
|
|
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", (
|
|
36
|
-
const a =
|
|
33
|
+
app.commandAsync("wait", (args, resolve) => {
|
|
34
|
+
const a = args as { ms: number };
|
|
37
35
|
app.sleep(a.ms, () => {
|
|
38
|
-
resolve(
|
|
36
|
+
resolve("waited " + a.ms + "ms without blocking the UI");
|
|
39
37
|
});
|
|
40
38
|
});
|
|
41
39
|
|
|
42
|
-
|
|
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
|
}
|