janela 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -14
- package/bin/janela.mjs +148 -13
- package/package.json +3 -3
- package/runtime/janela.ts +248 -101
- package/shim/wvshim.cc +359 -46
- package/templates/index.html +24 -0
- package/templates/main.ts +36 -16
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,12 +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
|
+
declare function wvDefer(h: number): number;
|
|
26
|
+
declare function wvResolve(h: number, id: number, status: number): number;
|
|
27
|
+
declare function wvTickStart(h: number, intervalMs: number): number;
|
|
28
|
+
declare function wvTickStop(h: number): number;
|
|
29
|
+
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;
|
|
34
|
+
|
|
35
|
+
const FS_PENDING = 0;
|
|
36
|
+
const FS_OK = 1;
|
|
25
37
|
|
|
26
38
|
// Injected into every page before it loads (webview_init).
|
|
27
39
|
const BOOTSTRAP =
|
|
@@ -40,10 +52,34 @@ const BOOTSTRAP =
|
|
|
40
52
|
" for (var i = 0; i < cbs.length; i++) cbs[i](payload);" +
|
|
41
53
|
"};";
|
|
42
54
|
|
|
43
|
-
// Handlers
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
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;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* An async command: return immediately, answer later. `resolve`/`reject` take
|
|
66
|
+
* a value and settle the page's `await janela.invoke(...)` promise whenever
|
|
67
|
+
* they are called — from a later defer()/sleep() turn, or from another
|
|
68
|
+
* command. The window stays responsive for as long as the call is pending.
|
|
69
|
+
*/
|
|
70
|
+
export type AsyncCommandHandler = (
|
|
71
|
+
args: unknown,
|
|
72
|
+
resolve: (value: unknown) => void,
|
|
73
|
+
reject: (reason: unknown) => void,
|
|
74
|
+
) => void;
|
|
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;
|
|
47
83
|
|
|
48
84
|
export interface WindowConfig {
|
|
49
85
|
title: string;
|
|
@@ -57,85 +93,39 @@ export interface JanelaApp {
|
|
|
57
93
|
handlers: CommandHandler[];
|
|
58
94
|
/** Register a named command, callable from the page as janela.invoke(name, args). */
|
|
59
95
|
command: (name: string, h: CommandHandler) => void;
|
|
60
|
-
/**
|
|
61
|
-
|
|
96
|
+
/** Register a command that answers later; see AsyncCommandHandler. */
|
|
97
|
+
commandAsync: (name: string, h: AsyncCommandHandler) => void;
|
|
98
|
+
/** Run fn on the next turn of the host loop — the way to slice long work. */
|
|
99
|
+
defer: (fn: () => void) => void;
|
|
100
|
+
/** Run fn after at least ms. The host loop's timer; scriptc's setTimeout
|
|
101
|
+
* cannot fire while the window is open (its loop is parked inside run()). */
|
|
102
|
+
sleep: (ms: number, fn: () => void) => 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;
|
|
62
118
|
/** Close the window and make run() return. */
|
|
63
119
|
quit: () => void;
|
|
64
120
|
/** Show the page and block until the window closes. Returns the run status. */
|
|
65
121
|
run: (html: string) => number;
|
|
66
122
|
}
|
|
67
123
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
function
|
|
71
|
-
return
|
|
72
|
-
|
|
73
|
-
HEX.charAt((unit >> 12) & 0xf) +
|
|
74
|
-
HEX.charAt((unit >> 8) & 0xf) +
|
|
75
|
-
HEX.charAt((unit >> 4) & 0xf) +
|
|
76
|
-
HEX.charAt(unit & 0xf)
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// The request arrives as UTF-8 JSON bytes. scriptc strings cannot hold lone
|
|
81
|
-
// surrogates (String.fromCharCode(0xd83d) yields a replacement char), so we
|
|
82
|
-
// never build non-ASCII chars directly: every code point >= 0x80 is re-emitted
|
|
83
|
-
// as a JSON \uXXXX escape (a surrogate PAIR of escapes for astral planes),
|
|
84
|
-
// which JSON.parse reconstructs correctly. Legal only because the payload is
|
|
85
|
-
// always JSON, where non-ASCII can only occur inside strings.
|
|
86
|
-
function readRequest(h: number): string {
|
|
87
|
-
let out = "";
|
|
88
|
-
const n = wvReqLen(h) + 0;
|
|
89
|
-
let i = 0;
|
|
90
|
-
while (i < n) {
|
|
91
|
-
const b0 = wvReqByte(h, i) + 0;
|
|
92
|
-
i = i + 1;
|
|
93
|
-
let cp = b0;
|
|
94
|
-
if ((b0 & 0xe0) === 0xc0 && i < n) {
|
|
95
|
-
cp = ((b0 & 0x1f) << 6) | (wvReqByte(h, i) & 0x3f);
|
|
96
|
-
i = i + 1;
|
|
97
|
-
} else if ((b0 & 0xf0) === 0xe0 && i + 1 < n) {
|
|
98
|
-
cp = ((b0 & 0x0f) << 12) | ((wvReqByte(h, i) & 0x3f) << 6) | (wvReqByte(h, i + 1) & 0x3f);
|
|
99
|
-
i = i + 2;
|
|
100
|
-
} else if ((b0 & 0xf8) === 0xf0 && i + 2 < n) {
|
|
101
|
-
cp =
|
|
102
|
-
((b0 & 0x07) << 18) |
|
|
103
|
-
((wvReqByte(h, i) & 0x3f) << 12) |
|
|
104
|
-
((wvReqByte(h, i + 1) & 0x3f) << 6) |
|
|
105
|
-
(wvReqByte(h, i + 2) & 0x3f);
|
|
106
|
-
i = i + 3;
|
|
107
|
-
}
|
|
108
|
-
if (cp < 0x80) {
|
|
109
|
-
out = out + String.fromCharCode(cp);
|
|
110
|
-
} else if (cp < 0x10000) {
|
|
111
|
-
out = out + uEscape(cp);
|
|
112
|
-
} else {
|
|
113
|
-
const v = cp - 0x10000;
|
|
114
|
-
out = out + uEscape(0xd800 + (v >> 10)) + uEscape(0xdc00 + (v & 0x3ff));
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return out;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// The reply must be valid JSON when it reaches the page. Non-ASCII chars can
|
|
121
|
-
// only legally occur inside JSON strings, where a \uXXXX escape is always
|
|
122
|
-
// equivalent — so escaping every char >127 keeps the byte channel ASCII-clean
|
|
123
|
-
// (surrogate halves escape individually, which JSON also permits).
|
|
124
|
-
function writeReply(h: number, body: string): void {
|
|
125
|
-
wvReplyReset(h);
|
|
126
|
-
for (let i = 0; i < body.length; i++) {
|
|
127
|
-
const c = body.charCodeAt(i);
|
|
128
|
-
if (c < 0x80) {
|
|
129
|
-
wvReplyPush(h, c);
|
|
130
|
-
} else {
|
|
131
|
-
wvReplyPush(h, 92); // backslash
|
|
132
|
-
wvReplyPush(h, 117); // 'u'
|
|
133
|
-
wvReplyPush(h, HEX.charCodeAt((c >> 12) & 0xf));
|
|
134
|
-
wvReplyPush(h, HEX.charCodeAt((c >> 8) & 0xf));
|
|
135
|
-
wvReplyPush(h, HEX.charCodeAt((c >> 4) & 0xf));
|
|
136
|
-
wvReplyPush(h, HEX.charCodeAt(c & 0xf));
|
|
137
|
-
}
|
|
138
|
-
}
|
|
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);
|
|
139
129
|
}
|
|
140
130
|
|
|
141
131
|
export function createApp(cfg: WindowConfig): JanelaApp {
|
|
@@ -144,6 +134,100 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
144
134
|
wvSetSize(h, cfg.width, cfg.height, 0);
|
|
145
135
|
wvInit(h, BOOTSTRAP);
|
|
146
136
|
|
|
137
|
+
// ---- the host loop -------------------------------------------------------
|
|
138
|
+
// scriptc's event loop is parked for as long as the program sits inside the
|
|
139
|
+
// wvRun() FFI call, so setTimeout/await never fire while the window is open.
|
|
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.
|
|
143
|
+
const asyncNames: string[] = [];
|
|
144
|
+
const asyncHandlers: AsyncCommandHandler[] = [];
|
|
145
|
+
let taskFns: (() => void)[] = [];
|
|
146
|
+
let timerFns: (() => void)[] = [];
|
|
147
|
+
let timerDue: number[] = [];
|
|
148
|
+
let fsIds: number[] = [];
|
|
149
|
+
let fsCbs: FsCallback[] = [];
|
|
150
|
+
let ticking = false;
|
|
151
|
+
|
|
152
|
+
const wake = (): void => {
|
|
153
|
+
if (ticking) return;
|
|
154
|
+
ticking = true;
|
|
155
|
+
wvTickStart(h, 8);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const idle = (): void => {
|
|
159
|
+
if (!ticking) return;
|
|
160
|
+
if (taskFns.length > 0 || timerFns.length > 0 || fsIds.length > 0) return;
|
|
161
|
+
ticking = false;
|
|
162
|
+
wvTickStop(h);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// One turn of the loop: every task queued so far, plus every due timer.
|
|
166
|
+
// Tasks queued *by* this turn wait for the next one, so a defer() chain
|
|
167
|
+
// yields to the UI between slices instead of starving it.
|
|
168
|
+
const turn = (): void => {
|
|
169
|
+
const tasks = taskFns;
|
|
170
|
+
taskFns = [];
|
|
171
|
+
for (let i = 0; i < tasks.length; i++) tasks[i]();
|
|
172
|
+
|
|
173
|
+
if (timerFns.length > 0) {
|
|
174
|
+
const now = Date.now() + 0;
|
|
175
|
+
const keptFns: (() => void)[] = [];
|
|
176
|
+
const keptDue: number[] = [];
|
|
177
|
+
const fire: (() => void)[] = [];
|
|
178
|
+
for (let i = 0; i < timerFns.length; i++) {
|
|
179
|
+
if (timerDue[i] <= now) {
|
|
180
|
+
fire.push(timerFns[i]);
|
|
181
|
+
} else {
|
|
182
|
+
keptFns.push(timerFns[i]);
|
|
183
|
+
keptDue.push(timerDue[i]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
timerFns = keptFns;
|
|
187
|
+
timerDue = keptDue;
|
|
188
|
+
for (let i = 0; i < fire.length; i++) fire[i]();
|
|
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
|
+
}
|
|
228
|
+
idle();
|
|
229
|
+
};
|
|
230
|
+
|
|
147
231
|
const app: JanelaApp = {
|
|
148
232
|
handle: h,
|
|
149
233
|
names: [],
|
|
@@ -154,10 +238,50 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
154
238
|
app.handlers.push(handler);
|
|
155
239
|
},
|
|
156
240
|
|
|
157
|
-
|
|
241
|
+
commandAsync: (name, handler) => {
|
|
242
|
+
asyncNames.push(name);
|
|
243
|
+
asyncHandlers.push(handler);
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
defer: (fn) => {
|
|
247
|
+
taskFns.push(fn);
|
|
248
|
+
wake();
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
sleep: (ms, fn) => {
|
|
252
|
+
timerFns.push(fn);
|
|
253
|
+
timerDue.push(Date.now() + (ms > 0 ? ms : 0));
|
|
254
|
+
wake();
|
|
255
|
+
},
|
|
256
|
+
|
|
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) => {
|
|
158
282
|
wvEval(
|
|
159
283
|
h,
|
|
160
|
-
"window.__wvEmit(" + JSON.stringify(event) + "," +
|
|
284
|
+
"window.__wvEmit(" + JSON.stringify(event) + "," + encode(payload) + ");",
|
|
161
285
|
);
|
|
162
286
|
},
|
|
163
287
|
|
|
@@ -166,26 +290,49 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
166
290
|
},
|
|
167
291
|
|
|
168
292
|
run: (html) => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
writeReply(h, '"unknown binding"');
|
|
175
|
-
return 1;
|
|
176
|
-
}
|
|
177
|
-
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[];
|
|
178
298
|
const cmd = env[0];
|
|
179
|
-
const
|
|
299
|
+
const args = JSON.parse(env[1]) as unknown;
|
|
180
300
|
for (let i = 0; i < app.names.length; i++) {
|
|
181
301
|
if (app.names[i] === cmd) {
|
|
182
|
-
|
|
302
|
+
wvReply(h, encode(app.handlers[i](args)));
|
|
183
303
|
return 0;
|
|
184
304
|
}
|
|
185
305
|
}
|
|
186
|
-
|
|
306
|
+
for (let i = 0; i < asyncNames.length; i++) {
|
|
307
|
+
if (asyncNames[i] === cmd) {
|
|
308
|
+
// Park the page's promise: the shim holds this call's id and
|
|
309
|
+
// answers it when resolve/reject reaches wvResolve, whenever
|
|
310
|
+
// that is. Meanwhile the loop is free to serve other calls.
|
|
311
|
+
const id = wvDefer(h) + 0;
|
|
312
|
+
if (id < 0) {
|
|
313
|
+
wvReply(h, encode("cannot defer command: " + cmd));
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
const settle = (status: number): ((value: unknown) => void) => {
|
|
317
|
+
let done = false;
|
|
318
|
+
return (value: unknown) => {
|
|
319
|
+
if (done) return; // a promise settles once
|
|
320
|
+
done = true;
|
|
321
|
+
wvReply(h, encode(value));
|
|
322
|
+
wvResolve(h, id, status);
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
asyncHandlers[i](args, settle(0), settle(1));
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
wvReply(h, encode("unknown command: " + cmd));
|
|
187
330
|
return 1; // rejects the frontend promise
|
|
188
|
-
})
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
wvBind(h, "__invoke");
|
|
334
|
+
wvSetHtml(h, html);
|
|
335
|
+
const rc = wvRun(h) + 0;
|
|
189
336
|
return rc;
|
|
190
337
|
},
|
|
191
338
|
};
|