janela 0.7.0 → 0.9.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 +48 -10
- package/api/index.d.ts +10 -6
- package/bin/janela.mjs +15 -13
- package/package.json +1 -1
- package/runtime/janela.ts +124 -88
- package/runtime/types.ts +66 -3
- package/shim/wvshim.cc +166 -59
- package/templates/react/files/src-host/main.ts +7 -8
- package/templates/solid/files/src-host/main.ts +7 -8
- package/templates/svelte/files/src-host/main.ts +7 -8
- package/templates/vue/files/src-host/main.ts +7 -8
package/README.md
CHANGED
|
@@ -141,9 +141,10 @@ framework templates' default.
|
|
|
141
141
|
import type { JanelaApp } from "janela/host";
|
|
142
142
|
|
|
143
143
|
export type AppCommands = {
|
|
144
|
-
add:
|
|
145
|
-
greet:
|
|
146
|
-
wait:
|
|
144
|
+
add: (args: { a: number; b: number }) => number;
|
|
145
|
+
greet: (args: { name: string }) => string;
|
|
146
|
+
wait: (args: { ms: number }) => string;
|
|
147
|
+
quit: () => void; // a command that takes nothing
|
|
147
148
|
};
|
|
148
149
|
export type AppEvents = { added: number };
|
|
149
150
|
|
|
@@ -195,11 +196,23 @@ Two things worth knowing:
|
|
|
195
196
|
own declarations rather than from an assertion you write by hand, which is
|
|
196
197
|
only possible because both sides are TypeScript.
|
|
197
198
|
|
|
198
|
-
|
|
199
|
-
|
|
199
|
+
A command that takes nothing is declared `() => void`, and its handler
|
|
200
|
+
returns `null` — every command answers the page's promise with a value, so
|
|
201
|
+
`void` is normalised to `null`. The page calls it as
|
|
202
|
+
`client.invoke("quit", null)`.
|
|
200
203
|
|
|
201
|
-
|
|
202
|
-
|
|
204
|
+
An **event payload is a single value** of any JSON-shaped type. For an event
|
|
205
|
+
carrying several things, prefer an object (`{ done: number; total: number }`):
|
|
206
|
+
adding a field later does not break existing listeners, and the names read
|
|
207
|
+
better at the call site. A tuple works too, but note that only the payload
|
|
208
|
+
*value* may be a tuple — the varargs spelling `app.emit("progress", 3, 10)`
|
|
209
|
+
does not compile (`SC2011: values of type '[done: number, total: number]'
|
|
210
|
+
have no static representation`, which would require `--dynamic` and ~620 KB
|
|
211
|
+
of embedded engine).
|
|
212
|
+
|
|
213
|
+
The `{ args; result }` record form of 0.5.x–0.7.x is still accepted, and the
|
|
214
|
+
untyped `invoke` / `listen` still work unchanged; the contract is additive,
|
|
215
|
+
and the `vanilla` template still uses the global.
|
|
203
216
|
|
|
204
217
|
## Async commands
|
|
205
218
|
|
|
@@ -266,9 +279,10 @@ in scriptc and can cost far more than the read did.
|
|
|
266
279
|
**Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
|
|
267
280
|
as long as the program sits inside the `run()` FFI call, so `setTimeout`,
|
|
268
281
|
`queueMicrotask` and `await` in host code never fire while the window is open
|
|
269
|
-
(they all run after it closes). janela
|
|
270
|
-
|
|
271
|
-
|
|
282
|
+
(they all run after it closes). janela schedules through the shell instead: the
|
|
283
|
+
runtime parks a continuation under an id, the shell keeps the clock and calls
|
|
284
|
+
it back on the UI thread when it comes due. Nothing polls, so an idle app
|
|
285
|
+
costs nothing at all.
|
|
272
286
|
|
|
273
287
|
**Still single-threaded.** scriptc's runtime is not thread-safe (concurrent
|
|
274
288
|
calls from several threads abort the process), so host code always runs on the
|
|
@@ -310,6 +324,30 @@ nested modal loop would otherwise re-enter the host loop underneath a live TS
|
|
|
310
324
|
frame; [docs/native-shell.md](../../docs/native-shell.md) has the details, the
|
|
311
325
|
per-platform table, and the Windows GUI-subsystem note.
|
|
312
326
|
|
|
327
|
+
## Migrating from 0.7.x
|
|
328
|
+
|
|
329
|
+
Commands are declared as the functions they are. The old `{ args; result }`
|
|
330
|
+
record form still compiles, so this is optional — but the function form is
|
|
331
|
+
shorter, and a command that takes nothing is finally natural to write.
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
// 0.7.x
|
|
335
|
+
export type AppCommands = {
|
|
336
|
+
add: { args: { a: number; b: number }; result: number };
|
|
337
|
+
quit: { args: null; result: null };
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// 0.8.0
|
|
341
|
+
export type AppCommands = {
|
|
342
|
+
add: (args: { a: number; b: number }) => number;
|
|
343
|
+
quit: () => void;
|
|
344
|
+
};
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Nothing else changes: `export type App = JanelaApp<AppCommands, AppEvents>`,
|
|
348
|
+
`setup(app: App)`, `app.command(...)` and the page's `createClient<App>()` are
|
|
349
|
+
all as they were. A handler for a `() => void` command returns `null`.
|
|
350
|
+
|
|
313
351
|
## Migrating from 0.6.x
|
|
314
352
|
|
|
315
353
|
The contract lives entirely in the types now, so the runtime tokens are gone.
|
package/api/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* parse.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type {
|
|
9
|
+
import type { JanelaAppImpl } from "../runtime/janela";
|
|
10
10
|
import type { CommandShapes, Commands, Events } from "../runtime/types";
|
|
11
11
|
|
|
12
12
|
/** Removes a subscription created by `listen` / `client.on`. */
|
|
@@ -65,18 +65,22 @@ export interface Contract {
|
|
|
65
65
|
/**
|
|
66
66
|
* The command table declared by a contract.
|
|
67
67
|
*
|
|
68
|
-
* Reads the contract off
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* Reads the contract off the app type — what a host's `App` is from 0.7.0 —
|
|
69
|
+
* and falls back to the 0.5.x/0.6.x `{ commands, events }` wrapper, so a
|
|
70
|
+
* project written against either shape keeps checking.
|
|
71
|
+
*
|
|
72
|
+
* The inference targets the class rather than the `JanelaApp` alias: the
|
|
73
|
+
* alias applies `Norm<C>`, which cannot be inferred backwards, and the
|
|
74
|
+
* normalised table is what indexing wants in any case.
|
|
71
75
|
*/
|
|
72
|
-
export type CommandsOf<A> = A extends
|
|
76
|
+
export type CommandsOf<A> = A extends JanelaAppImpl<infer M, infer _E>
|
|
73
77
|
? M
|
|
74
78
|
: A extends { commands: Commands<infer M> }
|
|
75
79
|
? M
|
|
76
80
|
: never;
|
|
77
81
|
|
|
78
82
|
/** The event table declared by a contract; see CommandsOf for the two shapes. */
|
|
79
|
-
export type EventsOf<A> = A extends
|
|
83
|
+
export type EventsOf<A> = A extends JanelaAppImpl<infer _M, infer E>
|
|
80
84
|
? E
|
|
81
85
|
: A extends { events: Events<infer E> }
|
|
82
86
|
? E
|
package/bin/janela.mjs
CHANGED
|
@@ -324,21 +324,21 @@ function ffiManifest(shimLib) {
|
|
|
324
324
|
returns: "i32",
|
|
325
325
|
},
|
|
326
326
|
{
|
|
327
|
-
name: "
|
|
327
|
+
name: "wvOnTimer", symbol: "wv_on_timer",
|
|
328
328
|
params: [
|
|
329
329
|
"i32",
|
|
330
|
-
{ callback: { id: "
|
|
331
|
-
{ context: "
|
|
330
|
+
{ callback: { id: "timer", params: ["i32", { context: "timer" }], returns: "void", lifetime: "retained" } },
|
|
331
|
+
{ context: "timer" },
|
|
332
332
|
],
|
|
333
333
|
returns: "i32",
|
|
334
334
|
},
|
|
335
335
|
{ name: "wvRun", symbol: "wv_run", params: ["i32"], returns: "i32" },
|
|
336
336
|
{ name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
|
|
337
|
-
// async: deferred returns
|
|
337
|
+
// async: the held-reply table (deferred returns) plus shell-owned
|
|
338
|
+
// scheduling — TS parks a continuation id, the shell calls it back due.
|
|
338
339
|
{ name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
|
|
339
340
|
{ name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
|
|
340
|
-
{ name: "
|
|
341
|
-
{ name: "wvTickStop", symbol: "wv_tick_stop", params: ["i32"], returns: "i32" },
|
|
341
|
+
{ name: "wvSchedule", symbol: "wv_schedule", params: ["i32", "i32", "i32"], returns: "i32" },
|
|
342
342
|
// async file I/O: the blocking syscall runs on a shim worker thread
|
|
343
343
|
{ name: "wvFsRead", symbol: "wv_fs_read", params: ["i32", "string"], returns: "i32" },
|
|
344
344
|
{ name: "wvFsWrite", symbol: "wv_fs_write", params: ["i32", "string", "string"], returns: "i32" },
|
|
@@ -516,17 +516,19 @@ function build(root, { devUrl = null, gui = true } = {}) {
|
|
|
516
516
|
join(buildDir, "entry.ts"),
|
|
517
517
|
`// Generated by janela — do not edit.\n` +
|
|
518
518
|
`import { createApp } from "./janela";\n` +
|
|
519
|
-
`import type { CommandShapes,
|
|
519
|
+
`import type { CommandShapes, JanelaAppImpl } from "./janela";\n` +
|
|
520
520
|
`import { WINDOW } from "./config";\n` +
|
|
521
521
|
`import { INDEX_HTML } from "./frontend";\n` +
|
|
522
522
|
`import { setup } from "./main";\n\n` +
|
|
523
523
|
`// The app's type parameters are read back off setup()'s own signature,\n` +
|
|
524
|
-
`// so a contract-typed setup(app:
|
|
525
|
-
`//
|
|
526
|
-
`//
|
|
527
|
-
`//
|
|
528
|
-
|
|
529
|
-
|
|
524
|
+
`// so a contract-typed setup(app: App) and a plain setup(app: JanelaApp)\n` +
|
|
525
|
+
`// each get an app instantiated to match. scriptc monomorphises generic\n` +
|
|
526
|
+
`// classes, so the right instantiation must be CONSTRUCTED here - no cast\n` +
|
|
527
|
+
`// can bridge two of them. The inference reads the CLASS, not the\n` +
|
|
528
|
+
`// JanelaApp alias: the alias applies Norm<C>, which cannot be reversed,\n` +
|
|
529
|
+
`// and what is wanted here is the normalised table anyway.\n` +
|
|
530
|
+
`type CmdsOf<F> = F extends (app: JanelaAppImpl<infer C, infer _E>) => void ? C : CommandShapes;\n` +
|
|
531
|
+
`type EvtsOf<F> = F extends (app: JanelaAppImpl<infer _C, infer E>) => void ? E : Record<string, unknown>;\n\n` +
|
|
530
532
|
`const app = createApp<CmdsOf<typeof setup>, EvtsOf<typeof setup>>(WINDOW);\n` +
|
|
531
533
|
`setup(app);\n` +
|
|
532
534
|
`const rc = app.run(INDEX_HTML) + 0;\n` +
|
package/package.json
CHANGED
package/runtime/janela.ts
CHANGED
|
@@ -19,13 +19,12 @@ declare function wvEval(h: number, js: string): number;
|
|
|
19
19
|
declare function wvBind(h: number, name: string): number;
|
|
20
20
|
declare function wvReply(h: number, body: string): number;
|
|
21
21
|
declare function wvOnInvoke(h: number, cb: (req: string) => number): number;
|
|
22
|
-
declare function
|
|
22
|
+
declare function wvOnTimer(h: number, cb: (id: number) => void): number;
|
|
23
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
|
-
declare function
|
|
28
|
-
declare function wvTickStop(h: number): number;
|
|
27
|
+
declare function wvSchedule(h: number, id: number, ms: number): number;
|
|
29
28
|
declare function wvFsRead(h: number, path: string): number;
|
|
30
29
|
declare function wvFsWrite(h: number, path: string, data: string): number;
|
|
31
30
|
declare function wvJobStatus(h: number, id: number): number;
|
|
@@ -83,11 +82,16 @@ const BOOTSTRAP =
|
|
|
83
82
|
// user's editor can see them). Re-exported here because the compiled build
|
|
84
83
|
// resolves them through this module — see the specifier rewrite in the CLI.
|
|
85
84
|
export type {
|
|
85
|
+
ArgsOf,
|
|
86
86
|
AsyncCommandHandler,
|
|
87
87
|
CommandHandler,
|
|
88
88
|
CommandShape,
|
|
89
89
|
CommandShapes,
|
|
90
|
+
CommandSpec,
|
|
91
|
+
CommandSpecs,
|
|
90
92
|
Commands,
|
|
93
|
+
Norm,
|
|
94
|
+
ResultOf,
|
|
91
95
|
DialogFilter,
|
|
92
96
|
Events,
|
|
93
97
|
FsCallback,
|
|
@@ -105,7 +109,9 @@ import type {
|
|
|
105
109
|
AsyncCommandHandler,
|
|
106
110
|
CommandHandler,
|
|
107
111
|
CommandShapes,
|
|
112
|
+
CommandSpecs,
|
|
108
113
|
Commands,
|
|
114
|
+
Norm,
|
|
109
115
|
DialogFilter,
|
|
110
116
|
Events,
|
|
111
117
|
FsCallback,
|
|
@@ -141,11 +147,10 @@ function encodeFilters(filters: DialogFilter[] | undefined): string {
|
|
|
141
147
|
const DRAIN_BUDGET_MS = 4; // = a quarter of a 60fps frame
|
|
142
148
|
const DRAIN_SLICE = 131072; // 128 KB - granularity within the budget
|
|
143
149
|
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
const
|
|
148
|
-
const TICK_DRAIN_MS = 4;
|
|
150
|
+
// The shell posts this id when a file read or a dialog reaches a terminal
|
|
151
|
+
// state. It is not a continuation - it means "service the jobs you are
|
|
152
|
+
// waiting on". Continuation ids start at 1, so the two can never collide.
|
|
153
|
+
const TIMER_JOBS = -1;
|
|
149
154
|
|
|
150
155
|
/**
|
|
151
156
|
* A running janela app.
|
|
@@ -156,7 +161,7 @@ const TICK_DRAIN_MS = 4;
|
|
|
156
161
|
* interface (being signature-only) never is. A class receiver works even as a
|
|
157
162
|
* plain function parameter, which is what `setup(app)` is.
|
|
158
163
|
*/
|
|
159
|
-
export class
|
|
164
|
+
export class JanelaAppImpl<
|
|
160
165
|
C extends CommandShapes = CommandShapes,
|
|
161
166
|
E = Record<string, unknown>,
|
|
162
167
|
> {
|
|
@@ -164,21 +169,23 @@ export class JanelaApp<
|
|
|
164
169
|
names: string[] = [];
|
|
165
170
|
handlers: CommandHandler[] = [];
|
|
166
171
|
|
|
167
|
-
// ----
|
|
172
|
+
// ---- scheduling ----------------------------------------------------------
|
|
168
173
|
// scriptc's event loop is parked for as long as the program sits inside the
|
|
169
174
|
// wvRun() FFI call, so setTimeout/await never fire while the window is open.
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
175
|
+
// The shell schedules instead: a continuation is parked here under an id and
|
|
176
|
+
// handed to wvSchedule(), and the shim calls onTimer(id) back on the UI
|
|
177
|
+
// thread once it comes due. Nothing here polls and nothing wakes
|
|
178
|
+
// periodically - an idle app is genuinely idle.
|
|
179
|
+
//
|
|
180
|
+
// This is the same shape the iOS shell must use, where the compiled TS links
|
|
181
|
+
// no event loop at all and could not hold a timer even if it wanted to.
|
|
173
182
|
asyncNames: string[] = [];
|
|
174
183
|
asyncHandlers: AsyncCommandHandler[] = [];
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
contIds: number[] = [];
|
|
185
|
+
contFns: (() => void)[] = [];
|
|
186
|
+
nextCont = 1; // ids start at 1; TIMER_JOBS (-1) is the shell's own
|
|
178
187
|
jobIds: number[] = [];
|
|
179
188
|
jobCbs: FsCallback[] = [];
|
|
180
|
-
ticking = false;
|
|
181
|
-
tickMs = TICK_IDLE_MS;
|
|
182
189
|
|
|
183
190
|
// ---- the drain -----------------------------------------------------------
|
|
184
191
|
// A finished job's bytes still have to be decoded into a TypeScript string,
|
|
@@ -187,6 +194,7 @@ export class JanelaApp<
|
|
|
187
194
|
// decoded a slice at a time, giving the run loop the thread back between
|
|
188
195
|
// slices - total work is unchanged, but no single turn carries much of it.
|
|
189
196
|
drainIds: number[] = [];
|
|
197
|
+
draining = false; // a drain continuation is already queued
|
|
190
198
|
drainCbs: FsCallback[] = [];
|
|
191
199
|
drainOk: boolean[] = [];
|
|
192
200
|
drainParts: string[][] = [];
|
|
@@ -201,37 +209,49 @@ export class JanelaApp<
|
|
|
201
209
|
wvInit(h, BOOTSTRAP);
|
|
202
210
|
}
|
|
203
211
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
this.
|
|
214
|
-
this.
|
|
215
|
-
|
|
212
|
+
/**
|
|
213
|
+
* Park `fn` with the shell and ask to be called back in `ms`.
|
|
214
|
+
*
|
|
215
|
+
* The id is the whole protocol: TS keeps the closure, the shell keeps the
|
|
216
|
+
* clock, and neither needs to know anything else about the other.
|
|
217
|
+
*/
|
|
218
|
+
schedule(ms: number, fn: () => void): void {
|
|
219
|
+
const id = this.nextCont;
|
|
220
|
+
this.nextCont = id + 1;
|
|
221
|
+
this.contIds.push(id);
|
|
222
|
+
this.contFns.push(fn);
|
|
223
|
+
const delay = ms > 0 ? ms : 0;
|
|
224
|
+
// `+ 0` per the note at the top of this file: a bare FFI call is not safe
|
|
225
|
+
// in every position, and this one is silently dropped without it.
|
|
226
|
+
const rc = wvSchedule(this.handle, id, delay) + 0;
|
|
227
|
+
if (rc < 0) console.log("[janela] could not schedule continuation", id);
|
|
216
228
|
}
|
|
217
229
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
this.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
230
|
+
/** Run the continuation parked under `id`, if it is still waiting. */
|
|
231
|
+
runCont(id: number): void {
|
|
232
|
+
for (let i = 0; i < this.contIds.length; i++) {
|
|
233
|
+
if (this.contIds[i] === id) {
|
|
234
|
+
const fn = this.contFns[i];
|
|
235
|
+
// Unregister BEFORE running: a continuation that schedules another one
|
|
236
|
+
// must not disturb the entry being removed, and a continuation that
|
|
237
|
+
// throws must not stay parked forever.
|
|
238
|
+
this.contIds.splice(i, 1);
|
|
239
|
+
this.contFns.splice(i, 1);
|
|
240
|
+
fn();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
227
243
|
}
|
|
228
|
-
this.ticking = false;
|
|
229
|
-
wvTickStop(this.handle);
|
|
230
244
|
}
|
|
231
245
|
|
|
232
246
|
// Decode as much of the pending payloads as the budget allows, then yield.
|
|
233
247
|
// Slices are taken from one job at a time so a big read finishes promptly
|
|
234
248
|
// rather than every concurrent read finishing slowly.
|
|
249
|
+
/**
|
|
250
|
+
* Decode as much of the pending payloads as the budget allows, then hand the
|
|
251
|
+
* thread back. If work remains, a zero-delay continuation carries on at the
|
|
252
|
+
* top of the next turn, so a 100 MB read is spread across frames instead of
|
|
253
|
+
* freezing one.
|
|
254
|
+
*/
|
|
235
255
|
drainSome(): void {
|
|
236
256
|
if (this.drainIds.length === 0) return;
|
|
237
257
|
const started = Date.now() + 0;
|
|
@@ -274,38 +294,39 @@ export class JanelaApp<
|
|
|
274
294
|
// before starting another payload.
|
|
275
295
|
}
|
|
276
296
|
|
|
277
|
-
if (Date.now() - started >= DRAIN_BUDGET_MS)
|
|
297
|
+
if (Date.now() - started >= DRAIN_BUDGET_MS) {
|
|
298
|
+
this.drainMore();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
278
301
|
}
|
|
279
302
|
}
|
|
280
303
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
this.timerFns = keptFns;
|
|
303
|
-
this.timerDue = keptDue;
|
|
304
|
-
for (let i = 0; i < fire.length; i++) fire[i]();
|
|
304
|
+
/** Continue draining on the next turn, without stacking duplicate work. */
|
|
305
|
+
drainMore(): void {
|
|
306
|
+
if (this.drainIds.length === 0 || this.draining) return;
|
|
307
|
+
this.draining = true;
|
|
308
|
+
this.schedule(0, () => {
|
|
309
|
+
this.draining = false;
|
|
310
|
+
this.drainSome();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The shell calls here on the UI thread when something it was holding comes
|
|
316
|
+
* due: a continuation TS parked (id >= 1), or a job that finished
|
|
317
|
+
* (TIMER_JOBS). This is the only entry into the loop, and it always lands at
|
|
318
|
+
* the top of a fresh turn — never underneath a TS frame.
|
|
319
|
+
*/
|
|
320
|
+
onTimer(id: number): void {
|
|
321
|
+
if (id !== TIMER_JOBS) {
|
|
322
|
+
this.runCont(id);
|
|
323
|
+
return;
|
|
305
324
|
}
|
|
325
|
+
this.serviceJobs();
|
|
326
|
+
}
|
|
306
327
|
|
|
307
|
-
|
|
308
|
-
|
|
328
|
+
/** A job reached a terminal state: move the finished ones to the drain. */
|
|
329
|
+
serviceJobs(): void {
|
|
309
330
|
if (this.jobIds.length > 0) {
|
|
310
331
|
const keptIds: number[] = [];
|
|
311
332
|
const keptCbs: FsCallback[] = [];
|
|
@@ -339,8 +360,6 @@ export class JanelaApp<
|
|
|
339
360
|
}
|
|
340
361
|
|
|
341
362
|
this.drainSome();
|
|
342
|
-
this.retick();
|
|
343
|
-
this.idle();
|
|
344
363
|
}
|
|
345
364
|
|
|
346
365
|
// Both dialog kinds share one path: start the job, then let the same drain
|
|
@@ -364,8 +383,7 @@ export class JanelaApp<
|
|
|
364
383
|
encodeFilters(filters),
|
|
365
384
|
) + 0;
|
|
366
385
|
if (id < 0) {
|
|
367
|
-
this.
|
|
368
|
-
this.wake();
|
|
386
|
+
this.defer(() => cb(null, "EAGAIN: could not open a dialog"));
|
|
369
387
|
return;
|
|
370
388
|
}
|
|
371
389
|
this.jobIds.push(id);
|
|
@@ -377,7 +395,6 @@ export class JanelaApp<
|
|
|
377
395
|
// "null" is a cancel; anything else is a JSON array of paths.
|
|
378
396
|
cb(JSON.parse(text) as string[] | null);
|
|
379
397
|
});
|
|
380
|
-
this.wake();
|
|
381
398
|
}
|
|
382
399
|
|
|
383
400
|
/**
|
|
@@ -420,16 +437,14 @@ export class JanelaApp<
|
|
|
420
437
|
|
|
421
438
|
/** Run fn on the next turn of the host loop - the way to slice long work. */
|
|
422
439
|
defer(fn: () => void): void {
|
|
423
|
-
|
|
424
|
-
this.
|
|
440
|
+
// Zero delay: the shell posts straight to the next turn, no timer at all.
|
|
441
|
+
this.schedule(0, fn);
|
|
425
442
|
}
|
|
426
443
|
|
|
427
|
-
/** Run fn after at least ms. The
|
|
444
|
+
/** Run fn after at least ms. The shell owns the clock; scriptc's setTimeout
|
|
428
445
|
* cannot fire while the window is open (its loop is parked inside run()). */
|
|
429
446
|
sleep(ms: number, fn: () => void): void {
|
|
430
|
-
this.
|
|
431
|
-
this.timerDue.push(Date.now() + (ms > 0 ? ms : 0));
|
|
432
|
-
this.wake();
|
|
447
|
+
this.schedule(ms, fn);
|
|
433
448
|
}
|
|
434
449
|
|
|
435
450
|
/**
|
|
@@ -444,7 +459,6 @@ export class JanelaApp<
|
|
|
444
459
|
}
|
|
445
460
|
this.jobIds.push(id);
|
|
446
461
|
this.jobCbs.push(cb);
|
|
447
|
-
this.wake();
|
|
448
462
|
}
|
|
449
463
|
|
|
450
464
|
/** Write a file without blocking the window; cb(null) on success. */
|
|
@@ -458,7 +472,6 @@ export class JanelaApp<
|
|
|
458
472
|
// The write payload is empty on success; the shared callback shape just
|
|
459
473
|
// ignores the text argument.
|
|
460
474
|
this.jobCbs.push((err, _text) => cb(err));
|
|
461
|
-
this.wake();
|
|
462
475
|
}
|
|
463
476
|
|
|
464
477
|
/** Show the native "open" dialog; cb gets the paths, or null on cancel. */
|
|
@@ -524,8 +537,8 @@ export class JanelaApp<
|
|
|
524
537
|
const h = this.handle;
|
|
525
538
|
// Both handlers are retained: registered once here, called by the shim
|
|
526
539
|
// for as long as the window is open.
|
|
527
|
-
|
|
528
|
-
this.
|
|
540
|
+
wvOnTimer(h, (id) => {
|
|
541
|
+
this.onTimer(id);
|
|
529
542
|
});
|
|
530
543
|
wvOnInvoke(h, (req) => {
|
|
531
544
|
const env = JSON.parse(req) as string[];
|
|
@@ -571,11 +584,34 @@ export class JanelaApp<
|
|
|
571
584
|
}
|
|
572
585
|
}
|
|
573
586
|
|
|
587
|
+
/**
|
|
588
|
+
* A running janela app, typed by the contract it serves.
|
|
589
|
+
*
|
|
590
|
+
* This is an alias rather than the class itself so that a contract may be
|
|
591
|
+
* written as plain function types: `Norm` converts it to the record form the
|
|
592
|
+
* class indexes, at a point where the table is still concrete. Writing the
|
|
593
|
+
* record form directly keeps working — `Norm` is idempotent.
|
|
594
|
+
*
|
|
595
|
+
* ```ts
|
|
596
|
+
* export type AppCommands = { add: (args: { a: number; b: number }) => number };
|
|
597
|
+
* export type AppEvents = { added: number };
|
|
598
|
+
* export type App = JanelaApp<AppCommands, AppEvents>;
|
|
599
|
+
*
|
|
600
|
+
* export function setup(app: App): void {
|
|
601
|
+
* app.command("add", (args) => args.a + args.b); // args inferred, result checked
|
|
602
|
+
* }
|
|
603
|
+
* ```
|
|
604
|
+
*/
|
|
605
|
+
export type JanelaApp<
|
|
606
|
+
C extends CommandSpecs = CommandShapes,
|
|
607
|
+
E = Record<string, unknown>,
|
|
608
|
+
> = JanelaAppImpl<Norm<C>, E>;
|
|
609
|
+
|
|
574
610
|
export function createApp<
|
|
575
611
|
C extends CommandShapes = CommandShapes,
|
|
576
612
|
E = Record<string, unknown>,
|
|
577
|
-
>(cfg: WindowConfig):
|
|
578
|
-
return new
|
|
613
|
+
>(cfg: WindowConfig): JanelaAppImpl<C, E> {
|
|
614
|
+
return new JanelaAppImpl<C, E>(cfg);
|
|
579
615
|
}
|
|
580
616
|
|
|
581
617
|
// ---------------------------------------------------------------------------
|
|
@@ -586,7 +622,7 @@ export function createApp<
|
|
|
586
622
|
|
|
587
623
|
/** @deprecated Use `app.command(name, handler)` on a contract-typed app. */
|
|
588
624
|
export function on<M extends CommandShapes, K extends keyof M & string>(
|
|
589
|
-
app:
|
|
625
|
+
app: JanelaAppImpl,
|
|
590
626
|
_commands: Commands<M>,
|
|
591
627
|
name: K,
|
|
592
628
|
handler: (args: M[K]["args"]) => M[K]["result"],
|
|
@@ -596,7 +632,7 @@ export function on<M extends CommandShapes, K extends keyof M & string>(
|
|
|
596
632
|
|
|
597
633
|
/** @deprecated Use `app.commandAsync(name, handler)` on a contract-typed app. */
|
|
598
634
|
export function onAsync<M extends CommandShapes, K extends keyof M & string>(
|
|
599
|
-
app:
|
|
635
|
+
app: JanelaAppImpl,
|
|
600
636
|
_commands: Commands<M>,
|
|
601
637
|
name: K,
|
|
602
638
|
handler: (
|
|
@@ -615,7 +651,7 @@ export function onAsync<M extends CommandShapes, K extends keyof M & string>(
|
|
|
615
651
|
|
|
616
652
|
/** @deprecated Use `app.emit(event, payload)` on a contract-typed app. */
|
|
617
653
|
export function emit<E, K extends keyof E & string>(
|
|
618
|
-
app:
|
|
654
|
+
app: JanelaAppImpl,
|
|
619
655
|
_events: Events<E>,
|
|
620
656
|
name: K,
|
|
621
657
|
payload: E[K],
|
package/runtime/types.ts
CHANGED
|
@@ -90,15 +90,78 @@ export interface WindowConfig {
|
|
|
90
90
|
// tokens and their `define*` constructors are the 0.5.x/0.6.x shape, kept so
|
|
91
91
|
// projects written against it still compile.
|
|
92
92
|
|
|
93
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* One command's argument and result types, in normalised form.
|
|
95
|
+
*
|
|
96
|
+
* This is what the app class works with internally. A contract is *written*
|
|
97
|
+
* as plain function types — see CommandSpec — and normalised to this by
|
|
98
|
+
* `Norm` before it reaches the class.
|
|
99
|
+
*/
|
|
94
100
|
export interface CommandShape {
|
|
95
101
|
args: unknown;
|
|
96
102
|
result: unknown;
|
|
97
103
|
}
|
|
98
104
|
|
|
99
|
-
/** A
|
|
105
|
+
/** A normalised command table: name → shape. */
|
|
100
106
|
export type CommandShapes = Record<string, CommandShape>;
|
|
101
107
|
|
|
108
|
+
/**
|
|
109
|
+
* How a command may be declared in a contract: as a plain function type
|
|
110
|
+
* (preferred), or as the `{ args; result }` record of 0.5.x–0.7.x.
|
|
111
|
+
*
|
|
112
|
+
* ```ts
|
|
113
|
+
* type AppCommands = {
|
|
114
|
+
* add: (args: { a: number; b: number }) => number;
|
|
115
|
+
* quit: () => void; // no arguments
|
|
116
|
+
* legacy: { args: { name: string }; result: string }; // still accepted
|
|
117
|
+
* };
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
export type CommandSpec = ((...args: never[]) => unknown) | CommandShape;
|
|
121
|
+
|
|
122
|
+
/** A contract's command table as written: name → spec. */
|
|
123
|
+
export type CommandSpecs = Record<string, CommandSpec>;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The argument type of a declared command. A function's single parameter, or
|
|
127
|
+
* a record's `args`. A command declared with no parameters takes `null` — the
|
|
128
|
+
* page's `invoke(name)` sends null, and nothing is lost.
|
|
129
|
+
*/
|
|
130
|
+
export type ArgsOf<F> = F extends (...a: infer P) => unknown
|
|
131
|
+
? P extends [infer A]
|
|
132
|
+
? A
|
|
133
|
+
: null
|
|
134
|
+
: F extends { args: infer A }
|
|
135
|
+
? A
|
|
136
|
+
: null;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The result type of a declared command. `void` is normalised to `null`:
|
|
140
|
+
* every command answers the page's promise with a value, and scriptc has no
|
|
141
|
+
* conversion from a void value to the `unknown` the handler table holds.
|
|
142
|
+
*/
|
|
143
|
+
export type ResultOf<F> = F extends (...a: never[]) => infer R
|
|
144
|
+
? [R] extends [void]
|
|
145
|
+
? null
|
|
146
|
+
: R
|
|
147
|
+
: F extends { result: infer R }
|
|
148
|
+
? R
|
|
149
|
+
: never;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Normalise a written contract to the record form the app class indexes.
|
|
153
|
+
*
|
|
154
|
+
* This runs where `C` is still concrete — in the `JanelaApp<C, E>` alias, one
|
|
155
|
+
* step before the class — on purpose. scriptc cannot compile a *value* whose
|
|
156
|
+
* type is an unresolved conditional or a mapped type indexed by a type
|
|
157
|
+
* parameter (`SC2001: values of type 'ArgsOf<C[K]>' cannot be compiled yet`),
|
|
158
|
+
* so the class body only ever sees plain indexed access on a record.
|
|
159
|
+
* Idempotent: normalising a record-form table returns it unchanged.
|
|
160
|
+
*/
|
|
161
|
+
export type Norm<C> = {
|
|
162
|
+
[K in keyof C]: { args: ArgsOf<C[K]>; result: ResultOf<C[K]> };
|
|
163
|
+
};
|
|
164
|
+
|
|
102
165
|
/**
|
|
103
166
|
* A declared command contract. Carries `M` at the type level only — the value
|
|
104
167
|
* is empty, and exists so that inference has something to read at a call site.
|
|
@@ -119,7 +182,7 @@ export interface Events<E> {
|
|
|
119
182
|
* types and name the app itself:
|
|
120
183
|
*
|
|
121
184
|
* ```ts
|
|
122
|
-
* export type AppCommands = { add:
|
|
185
|
+
* export type AppCommands = { add: (args: { a: number; b: number }) => number };
|
|
123
186
|
* export type AppEvents = { added: number };
|
|
124
187
|
* export type App = JanelaApp<AppCommands, AppEvents>;
|
|
125
188
|
* export function setup(app: App): void { … }
|
package/shim/wvshim.cc
CHANGED
|
@@ -8,15 +8,22 @@
|
|
|
8
8
|
// * format 3 — callback params may be `string`/`bytes`, so a payload crosses
|
|
9
9
|
// into TS as one argument instead of one FFI call per byte. Payloads going
|
|
10
10
|
// the other way ride `string` params on ordinary functions.
|
|
11
|
-
// * format 4 — callbacks may be `retained`, so the invoke and
|
|
11
|
+
// * format 4 — callbacks may be `retained`, so the invoke and timer handlers
|
|
12
12
|
// are registered once and live for the app's lifetime. wv_run() is a plain
|
|
13
13
|
// blocking call again; it no longer has to carry a callback whose "call
|
|
14
14
|
// scope" was standing in for "app lifetime".
|
|
15
|
+
//
|
|
16
|
+
// The shell owns scheduling. TS never holds a timer: it registers a
|
|
17
|
+
// continuation under an id and calls wv_schedule(), and this shim calls back
|
|
18
|
+
// into TS with that id once the delay is up. That is the same shape a library
|
|
19
|
+
// -mode host (iOS) must use, where the compiled TS links no event loop at all.
|
|
15
20
|
|
|
16
21
|
#include "webview.h"
|
|
17
22
|
|
|
23
|
+
#include <algorithm>
|
|
18
24
|
#include <atomic>
|
|
19
25
|
#include <chrono>
|
|
26
|
+
#include <condition_variable>
|
|
20
27
|
#include <cstdint>
|
|
21
28
|
#include <cstdio>
|
|
22
29
|
#include <cstring>
|
|
@@ -50,6 +57,13 @@ struct Pending {
|
|
|
50
57
|
std::string call_id;
|
|
51
58
|
};
|
|
52
59
|
|
|
60
|
+
// A continuation TS has parked with the shell: run whatever TS registered
|
|
61
|
+
// under `id` once `due` has passed. The shell owns the clock; TS owns the id.
|
|
62
|
+
struct Timer {
|
|
63
|
+
int32_t id;
|
|
64
|
+
std::chrono::steady_clock::time_point due;
|
|
65
|
+
};
|
|
66
|
+
|
|
53
67
|
struct App {
|
|
54
68
|
webview_t w = nullptr;
|
|
55
69
|
bool used = false;
|
|
@@ -59,8 +73,8 @@ struct App {
|
|
|
59
73
|
// request rides in as a (ptr, len) string param.
|
|
60
74
|
int32_t (*on_invoke)(const uint8_t *, size_t, void *) = nullptr;
|
|
61
75
|
void *on_invoke_ctx = nullptr;
|
|
62
|
-
void (*
|
|
63
|
-
void *
|
|
76
|
+
void (*on_timer)(int32_t, void *) = nullptr;
|
|
77
|
+
void *on_timer_ctx = nullptr;
|
|
64
78
|
|
|
65
79
|
// Staging for the in-flight request.
|
|
66
80
|
std::string req; // JSON args array from JS
|
|
@@ -69,13 +83,26 @@ struct App {
|
|
|
69
83
|
uint32_t seq = 0;
|
|
70
84
|
|
|
71
85
|
// ---- async support ----
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
std::
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
// The held-reply table: an invoke whose answer is not ready yet. The page's
|
|
87
|
+
// promise stays unsettled until wv_resolve() answers this call id.
|
|
88
|
+
std::vector<Pending> pending;
|
|
89
|
+
bool deferred = false; // set by wv_defer() during the current call
|
|
90
|
+
|
|
91
|
+
// The shell's timer queue, and the one thread that watches it. The thread
|
|
92
|
+
// only sleeps and posts — it never touches TS, because scriptc's runtime is
|
|
93
|
+
// not thread-safe. Everything reaches TS through webview_dispatch, on the UI
|
|
94
|
+
// thread, on a LATER turn of the shell's own loop (see timer_on_ui_thread).
|
|
95
|
+
std::vector<Timer> timers;
|
|
96
|
+
std::mutex timers_mu;
|
|
97
|
+
std::condition_variable timers_cv;
|
|
98
|
+
std::thread scheduler;
|
|
99
|
+
std::atomic<bool> scheduling{false};
|
|
77
100
|
};
|
|
78
101
|
|
|
102
|
+
// Reserved timer id: not a TS continuation but "a job changed state, service
|
|
103
|
+
// them". TS allocates its own continuation ids from 1 upwards.
|
|
104
|
+
const int32_t TIMER_JOBS = -1;
|
|
105
|
+
|
|
79
106
|
// Fixed-size table: handles are indices, never pointers.
|
|
80
107
|
App g_apps[8];
|
|
81
108
|
|
|
@@ -92,8 +119,9 @@ std::string to_str(const uint8_t *p, size_t n) {
|
|
|
92
119
|
// ---- jobs ------------------------------------------------------------------
|
|
93
120
|
//
|
|
94
121
|
// A job is any unit of work whose answer cannot be produced during the FFI
|
|
95
|
-
// call that asks for it. TS starts one
|
|
96
|
-
//
|
|
122
|
+
// call that asks for it. TS starts one and gets an id back immediately; when
|
|
123
|
+
// the job reaches a terminal state it posts TIMER_JOBS to the UI thread, and
|
|
124
|
+
// TS then reads wv_job_status() for the jobs it is waiting on.
|
|
97
125
|
//
|
|
98
126
|
// Two kinds use this pool, for opposite reasons:
|
|
99
127
|
// * file I/O — the blocking syscall must happen off the UI thread, so a
|
|
@@ -102,9 +130,9 @@ std::string to_str(const uint8_t *p, size_t n) {
|
|
|
102
130
|
// later, on the UI thread.
|
|
103
131
|
// * native dialogs — the modal must run ON the UI thread, but not while TS
|
|
104
132
|
// is on the stack (runModal/gtk_dialog_run spin a nested event loop, which
|
|
105
|
-
// would re-enter
|
|
106
|
-
//
|
|
107
|
-
//
|
|
133
|
+
// would re-enter TS underneath the invoke handler that asked for the
|
|
134
|
+
// dialog). So the job is posted with webview_dispatch and runs at the top
|
|
135
|
+
// of a later turn, with no TS frame beneath it.
|
|
108
136
|
|
|
109
137
|
const int32_t JOB_PENDING = 0;
|
|
110
138
|
const int32_t JOB_OK = 1;
|
|
@@ -119,6 +147,9 @@ struct Job {
|
|
|
119
147
|
std::string data; // payload on success, the error message on failure
|
|
120
148
|
std::thread worker; // unused by dialog jobs, which run on the UI thread
|
|
121
149
|
bool used = false;
|
|
150
|
+
// Which app to wake when this job finishes. Without the ticker there is
|
|
151
|
+
// nothing polling, so a finished job has to announce itself.
|
|
152
|
+
int32_t app = -1;
|
|
122
153
|
};
|
|
123
154
|
|
|
124
155
|
// Jobs are addressed by index and held behind unique_ptr so the vector may
|
|
@@ -135,7 +166,7 @@ Job *job_at(int32_t id) {
|
|
|
135
166
|
|
|
136
167
|
// Reuses a finished slot when one is free, so a long-running app that reads
|
|
137
168
|
// many files does not grow the table without bound.
|
|
138
|
-
int32_t new_job() {
|
|
169
|
+
int32_t new_job(int32_t app) {
|
|
139
170
|
std::lock_guard<std::mutex> lock(g_jobs_mu);
|
|
140
171
|
for (size_t i = 0; i < g_jobs.size(); i++) {
|
|
141
172
|
if (g_jobs[i]->used) continue;
|
|
@@ -143,10 +174,12 @@ int32_t new_job() {
|
|
|
143
174
|
g_jobs[i]->status.store(JOB_PENDING);
|
|
144
175
|
g_jobs[i]->data.clear();
|
|
145
176
|
g_jobs[i]->used = true;
|
|
177
|
+
g_jobs[i]->app = app;
|
|
146
178
|
return static_cast<int32_t>(i);
|
|
147
179
|
}
|
|
148
180
|
g_jobs.push_back(std::unique_ptr<Job>(new Job()));
|
|
149
181
|
g_jobs.back()->used = true;
|
|
182
|
+
g_jobs.back()->app = app;
|
|
150
183
|
return static_cast<int32_t>(g_jobs.size() - 1);
|
|
151
184
|
}
|
|
152
185
|
|
|
@@ -166,9 +199,18 @@ std::string fs_error_message(const std::string &path, const char *op) {
|
|
|
166
199
|
return "EIO: failed to " + std::string(op) + " '" + path + "'";
|
|
167
200
|
}
|
|
168
201
|
|
|
202
|
+
// Defined below, once the app table is in scope. Posts `id` to the app's UI
|
|
203
|
+
// thread via webview_dispatch, so TS is entered on a later turn of the shell's
|
|
204
|
+
// own loop and never underneath a frame it is already inside.
|
|
205
|
+
void post_timer(int32_t app, int32_t id);
|
|
206
|
+
|
|
169
207
|
void job_finish(Job *j, int32_t status, std::string payload) {
|
|
170
208
|
j->data = std::move(payload);
|
|
171
209
|
j->status.store(status, std::memory_order_release);
|
|
210
|
+
// Nothing polls any more, so a finished job announces itself. Safe from a
|
|
211
|
+
// worker thread: webview_dispatch is the documented cross-thread hand-off,
|
|
212
|
+
// and it only queues — TS runs later, on the UI thread.
|
|
213
|
+
if (j->app >= 0) post_timer(j->app, TIMER_JOBS);
|
|
172
214
|
}
|
|
173
215
|
|
|
174
216
|
void fs_read_worker(Job *j, std::string path) {
|
|
@@ -614,18 +656,67 @@ void trampoline(const char *id, const char *req, void *arg) {
|
|
|
614
656
|
webview_return(a->w, a->cur_id.c_str(), status,
|
|
615
657
|
a->reply.empty() ? "null" : a->reply.c_str());
|
|
616
658
|
// wv_defer() treats a non-empty cur_id as "an invoke is in flight". Clearing
|
|
617
|
-
// it here means a defer from anywhere else — a
|
|
659
|
+
// it here means a defer from anywhere else — a timer, say — fails with -1
|
|
618
660
|
// instead of stealing this already-answered call's id.
|
|
619
661
|
a->cur_id.clear();
|
|
620
662
|
}
|
|
621
663
|
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
void
|
|
625
|
-
|
|
626
|
-
|
|
664
|
+
// The app index and the timer id, packed into the single void* that
|
|
665
|
+
// webview_dispatch carries.
|
|
666
|
+
void *pack_timer(int32_t app, int32_t id) {
|
|
667
|
+
uintptr_t packed = (static_cast<uintptr_t>(static_cast<uint32_t>(app)) << 32) |
|
|
668
|
+
static_cast<uint32_t>(id);
|
|
669
|
+
return reinterpret_cast<void *>(packed);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Runs on the UI thread, posted via webview_dispatch, so the TS it calls stays
|
|
673
|
+
// single-threaded — scriptc's runtime is NOT thread-safe.
|
|
674
|
+
//
|
|
675
|
+
// This is also the one place that guarantees the shell never re-enters TS from
|
|
676
|
+
// inside a frame TS is already in. Everything that wants to reach TS — a due
|
|
677
|
+
// timer, a finished file read, a dismissed dialog — goes through a dispatch
|
|
678
|
+
// and therefore lands at the top of a later turn, with no TS beneath it. That
|
|
679
|
+
// rule is invisible when broken: a violating host gets correct-looking results
|
|
680
|
+
// right up until it doesn't, so it is kept by construction, not by testing.
|
|
681
|
+
void timer_on_ui_thread(webview_t, void *arg) {
|
|
682
|
+
uintptr_t packed = reinterpret_cast<uintptr_t>(arg);
|
|
683
|
+
App *a = &g_apps[packed >> 32];
|
|
684
|
+
int32_t id = static_cast<int32_t>(static_cast<uint32_t>(packed & 0xffffffffu));
|
|
685
|
+
if (!a->used || !a->on_timer) return; // app quit between dispatch and delivery
|
|
627
686
|
a->seq++;
|
|
628
|
-
a->
|
|
687
|
+
a->on_timer(id, a->on_timer_ctx);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
void post_timer(int32_t app, int32_t id) {
|
|
691
|
+
if (app < 0 || app >= 8) return;
|
|
692
|
+
App *a = &g_apps[app];
|
|
693
|
+
if (!a->used || !a->w) return;
|
|
694
|
+
webview_dispatch(a->w, timer_on_ui_thread, pack_timer(app, id));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// The scheduler thread: sleep until the earliest timer is due, hand its id to
|
|
698
|
+
// the UI thread, repeat. It never touches TS and holds no TS state.
|
|
699
|
+
void scheduler_loop(App *a, int32_t h) {
|
|
700
|
+
std::unique_lock<std::mutex> lk(a->timers_mu);
|
|
701
|
+
while (a->scheduling.load()) {
|
|
702
|
+
if (a->timers.empty()) {
|
|
703
|
+
a->timers_cv.wait(lk);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
auto soonest = std::min_element(
|
|
707
|
+
a->timers.begin(), a->timers.end(),
|
|
708
|
+
[](const Timer &x, const Timer &y) { return x.due < y.due; });
|
|
709
|
+
auto due = soonest->due;
|
|
710
|
+
if (due > std::chrono::steady_clock::now()) {
|
|
711
|
+
a->timers_cv.wait_until(lk, due);
|
|
712
|
+
continue; // re-check: an earlier timer may have arrived meanwhile
|
|
713
|
+
}
|
|
714
|
+
int32_t id = soonest->id;
|
|
715
|
+
a->timers.erase(soonest);
|
|
716
|
+
lk.unlock();
|
|
717
|
+
post_timer(h, id);
|
|
718
|
+
lk.lock();
|
|
719
|
+
}
|
|
629
720
|
}
|
|
630
721
|
|
|
631
722
|
} // namespace
|
|
@@ -642,16 +733,16 @@ int32_t wv_create(int32_t debug) {
|
|
|
642
733
|
g_apps[i].binds.clear();
|
|
643
734
|
g_apps[i].on_invoke = nullptr;
|
|
644
735
|
g_apps[i].on_invoke_ctx = nullptr;
|
|
645
|
-
g_apps[i].
|
|
646
|
-
g_apps[i].
|
|
736
|
+
g_apps[i].on_timer = nullptr;
|
|
737
|
+
g_apps[i].on_timer_ctx = nullptr;
|
|
647
738
|
g_apps[i].req.clear();
|
|
648
739
|
g_apps[i].cur_id.clear();
|
|
649
740
|
g_apps[i].reply.clear();
|
|
650
741
|
g_apps[i].seq = 0;
|
|
651
742
|
g_apps[i].pending.clear();
|
|
652
743
|
g_apps[i].deferred = false;
|
|
653
|
-
g_apps[i].
|
|
654
|
-
g_apps[i].
|
|
744
|
+
g_apps[i].scheduling.store(false);
|
|
745
|
+
g_apps[i].timers.clear();
|
|
655
746
|
g_apps[i].w = w;
|
|
656
747
|
g_apps[i].used = true;
|
|
657
748
|
return i;
|
|
@@ -723,8 +814,8 @@ int32_t wv_reply(int32_t h, const uint8_t *p, size_t n) {
|
|
|
723
814
|
// call, and wv_run() is one such call for the app's whole life — so setTimeout
|
|
724
815
|
// and promise continuations in TS never fire while the window is open. These
|
|
725
816
|
// four functions supply the missing loop: TS may postpone an invoke's answer
|
|
726
|
-
// (wv_defer), answer it later (wv_resolve), and
|
|
727
|
-
// on the UI thread
|
|
817
|
+
// (wv_defer), answer it later (wv_resolve), and park a continuation with the
|
|
818
|
+
// shell to be called back on the UI thread when it comes due (wv_schedule).
|
|
728
819
|
|
|
729
820
|
// Postpone the answer to the invoke being handled right now. Returns a
|
|
730
821
|
// pending id to hand back to wv_resolve(), or -1 outside a bind callback.
|
|
@@ -759,43 +850,60 @@ int32_t wv_resolve(int32_t h, int32_t id, int32_t status) {
|
|
|
759
850
|
return 0;
|
|
760
851
|
}
|
|
761
852
|
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
|
|
853
|
+
// Ask the shell to call the retained timer handler with `id` after `ms`.
|
|
854
|
+
//
|
|
855
|
+
// This is the whole of scheduling: TS keeps the continuation, the shell keeps
|
|
856
|
+
// the clock. A zero delay is not a special case — it posts on the next turn of
|
|
857
|
+
// the loop, which is exactly what app.defer() wants, with no timer involved.
|
|
858
|
+
//
|
|
859
|
+
// An idle app now costs nothing at all: with no timers queued the scheduler
|
|
860
|
+
// thread blocks on a condition variable rather than waking every few
|
|
861
|
+
// milliseconds to find nothing to do.
|
|
862
|
+
int32_t wv_schedule(int32_t h, int32_t id, int32_t ms) {
|
|
765
863
|
App *a = app_at(h);
|
|
766
864
|
if (!a) return -1;
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
865
|
+
|
|
866
|
+
// Zero delay skips the queue: there is nothing to wait for, and posting
|
|
867
|
+
// straight to the UI thread keeps a defer() chain as short as possible.
|
|
868
|
+
if (ms <= 0) {
|
|
869
|
+
post_timer(h, id);
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
{
|
|
874
|
+
std::lock_guard<std::mutex> lock(a->timers_mu);
|
|
875
|
+
a->timers.push_back(
|
|
876
|
+
Timer{id, std::chrono::steady_clock::now() +
|
|
877
|
+
std::chrono::milliseconds(ms)});
|
|
878
|
+
if (!a->scheduling.exchange(true)) {
|
|
879
|
+
a->scheduler = std::thread(scheduler_loop, a, h);
|
|
776
880
|
}
|
|
777
|
-
}
|
|
881
|
+
}
|
|
882
|
+
a->timers_cv.notify_one();
|
|
778
883
|
return 0;
|
|
779
884
|
}
|
|
780
885
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
if (!a->
|
|
785
|
-
|
|
786
|
-
|
|
886
|
+
// Stop the scheduler thread and drop any timers that never came due. Called on
|
|
887
|
+
// the way out of wv_run(), so nothing can reach TS after the window closes.
|
|
888
|
+
void stop_scheduler(App *a) {
|
|
889
|
+
if (!a->scheduling.exchange(false)) return;
|
|
890
|
+
a->timers_cv.notify_all();
|
|
891
|
+
if (a->scheduler.joinable()) a->scheduler.join();
|
|
892
|
+
std::lock_guard<std::mutex> lock(a->timers_mu);
|
|
893
|
+
a->timers.clear();
|
|
787
894
|
}
|
|
788
895
|
|
|
789
896
|
// ---- async file I/O ---------------------------------------------------------
|
|
790
897
|
//
|
|
791
898
|
// wv_fs_read/wv_fs_write start a worker thread and return immediately with a
|
|
792
|
-
// job id. TS
|
|
899
|
+
// job id. TS is woken with TIMER_JOBS when it finishes, reads wv_job_status()
|
|
900
|
+
// and drains the payload
|
|
793
901
|
// with wv_fs_byte() once the job is terminal. On failure the payload is the
|
|
794
902
|
// error message, so success and failure share one drain path.
|
|
795
903
|
|
|
796
904
|
int32_t wv_fs_read(int32_t h, const uint8_t *p, size_t n) {
|
|
797
905
|
if (!app_at(h)) return -1;
|
|
798
|
-
int32_t id = new_job();
|
|
906
|
+
int32_t id = new_job(h);
|
|
799
907
|
Job *j = job_at(id);
|
|
800
908
|
if (!j) return -1;
|
|
801
909
|
j->worker = std::thread(fs_read_worker, j, to_str(p, n));
|
|
@@ -805,7 +913,7 @@ int32_t wv_fs_read(int32_t h, const uint8_t *p, size_t n) {
|
|
|
805
913
|
int32_t wv_fs_write(int32_t h, const uint8_t *p, size_t n, const uint8_t *dp,
|
|
806
914
|
size_t dn) {
|
|
807
915
|
if (!app_at(h)) return -1;
|
|
808
|
-
int32_t id = new_job();
|
|
916
|
+
int32_t id = new_job(h);
|
|
809
917
|
Job *j = job_at(id);
|
|
810
918
|
if (!j) return -1;
|
|
811
919
|
j->worker = std::thread(fs_write_worker, j, to_str(p, n), to_str(dp, dn));
|
|
@@ -906,7 +1014,7 @@ int32_t wv_dialog(int32_t h, int32_t kind, int32_t flags, const uint8_t *tp,
|
|
|
906
1014
|
size_t nn, const uint8_t *fp, size_t fn) {
|
|
907
1015
|
App *a = app_at(h);
|
|
908
1016
|
if (!a) return -1;
|
|
909
|
-
int32_t id = new_job();
|
|
1017
|
+
int32_t id = new_job(h);
|
|
910
1018
|
Job *j = job_at(id);
|
|
911
1019
|
if (!j) return -1;
|
|
912
1020
|
|
|
@@ -994,12 +1102,13 @@ int32_t wv_on_invoke(int32_t h,
|
|
|
994
1102
|
return 0;
|
|
995
1103
|
}
|
|
996
1104
|
|
|
997
|
-
// Register the retained handler the
|
|
998
|
-
|
|
1105
|
+
// Register the retained handler the shell calls when a scheduled id comes due
|
|
1106
|
+
// (and with TIMER_JOBS when a file read or dialog finishes).
|
|
1107
|
+
int32_t wv_on_timer(int32_t h, void (*cb)(int32_t, void *), void *ctx) {
|
|
999
1108
|
App *a = app_at(h);
|
|
1000
1109
|
if (!a) return -1;
|
|
1001
|
-
a->
|
|
1002
|
-
a->
|
|
1110
|
+
a->on_timer = cb;
|
|
1111
|
+
a->on_timer_ctx = ctx;
|
|
1003
1112
|
return 0;
|
|
1004
1113
|
}
|
|
1005
1114
|
|
|
@@ -1009,11 +1118,10 @@ int32_t wv_run(int32_t h) {
|
|
|
1009
1118
|
if (!a) return -1;
|
|
1010
1119
|
int rc = webview_run(a->w);
|
|
1011
1120
|
// Nothing may call into TS once run() has returned.
|
|
1012
|
-
a
|
|
1013
|
-
if (a->ticker.joinable()) a->ticker.join();
|
|
1121
|
+
stop_scheduler(a);
|
|
1014
1122
|
jobs_join_all(); // nor may an in-flight read outlive the app
|
|
1015
1123
|
a->on_invoke = nullptr;
|
|
1016
|
-
a->
|
|
1124
|
+
a->on_timer = nullptr;
|
|
1017
1125
|
return rc;
|
|
1018
1126
|
}
|
|
1019
1127
|
|
|
@@ -1026,8 +1134,7 @@ int32_t wv_terminate(int32_t h) {
|
|
|
1026
1134
|
int32_t wv_destroy(int32_t h) {
|
|
1027
1135
|
App *a = app_at(h);
|
|
1028
1136
|
if (!a) return -1;
|
|
1029
|
-
a
|
|
1030
|
-
if (a->ticker.joinable()) a->ticker.join();
|
|
1137
|
+
stop_scheduler(a);
|
|
1031
1138
|
webview_destroy(a->w);
|
|
1032
1139
|
a->used = false;
|
|
1033
1140
|
a->w = nullptr;
|
|
@@ -9,19 +9,18 @@
|
|
|
9
9
|
// Two gotchas inherited from scriptc:
|
|
10
10
|
// - never use a bare FFI-backed call as a complete variable initializer;
|
|
11
11
|
// wrap it in any expression (`+ 0`);
|
|
12
|
-
// -
|
|
13
|
-
//
|
|
14
|
-
// to compile.
|
|
12
|
+
// - a command that returns nothing is declared `() => void` and its handler
|
|
13
|
+
// returns `null`; every command answers the page's promise with a value.
|
|
15
14
|
|
|
16
15
|
import type { JanelaApp } from "janela/host";
|
|
17
16
|
|
|
18
17
|
/** Every command this app answers. Declared once; the page checks against it. */
|
|
19
18
|
export type AppCommands = {
|
|
20
|
-
add:
|
|
21
|
-
greet:
|
|
22
|
-
log:
|
|
23
|
-
wait:
|
|
24
|
-
quit:
|
|
19
|
+
add: (args: { a: number; b: number }) => number;
|
|
20
|
+
greet: (args: { name: string }) => string;
|
|
21
|
+
log: (args: string) => void;
|
|
22
|
+
wait: (args: { ms: number }) => string;
|
|
23
|
+
quit: () => void;
|
|
25
24
|
};
|
|
26
25
|
|
|
27
26
|
/** Every event this app emits, and what each one carries. */
|
|
@@ -9,19 +9,18 @@
|
|
|
9
9
|
// Two gotchas inherited from scriptc:
|
|
10
10
|
// - never use a bare FFI-backed call as a complete variable initializer;
|
|
11
11
|
// wrap it in any expression (`+ 0`);
|
|
12
|
-
// -
|
|
13
|
-
//
|
|
14
|
-
// to compile.
|
|
12
|
+
// - a command that returns nothing is declared `() => void` and its handler
|
|
13
|
+
// returns `null`; every command answers the page's promise with a value.
|
|
15
14
|
|
|
16
15
|
import type { JanelaApp } from "janela/host";
|
|
17
16
|
|
|
18
17
|
/** Every command this app answers. Declared once; the page checks against it. */
|
|
19
18
|
export type AppCommands = {
|
|
20
|
-
add:
|
|
21
|
-
greet:
|
|
22
|
-
log:
|
|
23
|
-
wait:
|
|
24
|
-
quit:
|
|
19
|
+
add: (args: { a: number; b: number }) => number;
|
|
20
|
+
greet: (args: { name: string }) => string;
|
|
21
|
+
log: (args: string) => void;
|
|
22
|
+
wait: (args: { ms: number }) => string;
|
|
23
|
+
quit: () => void;
|
|
25
24
|
};
|
|
26
25
|
|
|
27
26
|
/** Every event this app emits, and what each one carries. */
|
|
@@ -9,19 +9,18 @@
|
|
|
9
9
|
// Two gotchas inherited from scriptc:
|
|
10
10
|
// - never use a bare FFI-backed call as a complete variable initializer;
|
|
11
11
|
// wrap it in any expression (`+ 0`);
|
|
12
|
-
// -
|
|
13
|
-
//
|
|
14
|
-
// to compile.
|
|
12
|
+
// - a command that returns nothing is declared `() => void` and its handler
|
|
13
|
+
// returns `null`; every command answers the page's promise with a value.
|
|
15
14
|
|
|
16
15
|
import type { JanelaApp } from "janela/host";
|
|
17
16
|
|
|
18
17
|
/** Every command this app answers. Declared once; the page checks against it. */
|
|
19
18
|
export type AppCommands = {
|
|
20
|
-
add:
|
|
21
|
-
greet:
|
|
22
|
-
log:
|
|
23
|
-
wait:
|
|
24
|
-
quit:
|
|
19
|
+
add: (args: { a: number; b: number }) => number;
|
|
20
|
+
greet: (args: { name: string }) => string;
|
|
21
|
+
log: (args: string) => void;
|
|
22
|
+
wait: (args: { ms: number }) => string;
|
|
23
|
+
quit: () => void;
|
|
25
24
|
};
|
|
26
25
|
|
|
27
26
|
/** Every event this app emits, and what each one carries. */
|
|
@@ -9,19 +9,18 @@
|
|
|
9
9
|
// Two gotchas inherited from scriptc:
|
|
10
10
|
// - never use a bare FFI-backed call as a complete variable initializer;
|
|
11
11
|
// wrap it in any expression (`+ 0`);
|
|
12
|
-
// -
|
|
13
|
-
//
|
|
14
|
-
// to compile.
|
|
12
|
+
// - a command that returns nothing is declared `() => void` and its handler
|
|
13
|
+
// returns `null`; every command answers the page's promise with a value.
|
|
15
14
|
|
|
16
15
|
import type { JanelaApp } from "janela/host";
|
|
17
16
|
|
|
18
17
|
/** Every command this app answers. Declared once; the page checks against it. */
|
|
19
18
|
export type AppCommands = {
|
|
20
|
-
add:
|
|
21
|
-
greet:
|
|
22
|
-
log:
|
|
23
|
-
wait:
|
|
24
|
-
quit:
|
|
19
|
+
add: (args: { a: number; b: number }) => number;
|
|
20
|
+
greet: (args: { name: string }) => string;
|
|
21
|
+
log: (args: string) => void;
|
|
22
|
+
wait: (args: { ms: number }) => string;
|
|
23
|
+
quit: () => void;
|
|
25
24
|
};
|
|
26
25
|
|
|
27
26
|
/** Every event this app emits, and what each one carries. */
|