janela 0.2.0 → 0.3.1
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 +71 -13
- package/bin/janela.mjs +355 -40
- package/package.json +1 -1
- package/runtime/janela.ts +280 -37
- package/shim/wvshim.cc +593 -70
- package/templates/index.html +17 -0
- package/templates/main.ts +32 -0
- package/templates/react/deps.json +4 -0
- package/templates/react/files/index.html +11 -0
- package/templates/react/files/janela.conf.json +10 -0
- package/templates/react/files/src/App.css +3 -0
- package/templates/react/files/src/App.jsx +38 -0
- package/templates/react/files/src/main.jsx +9 -0
- package/templates/react/files/src-host/main.ts +42 -0
- package/templates/react/files/vite.config.js +6 -0
- package/templates/solid/deps.json +4 -0
- package/templates/solid/files/index.html +11 -0
- package/templates/solid/files/janela.conf.json +10 -0
- package/templates/solid/files/src/App.css +3 -0
- package/templates/solid/files/src/App.jsx +35 -0
- package/templates/solid/files/src/main.jsx +4 -0
- package/templates/solid/files/src-host/main.ts +42 -0
- package/templates/solid/files/vite.config.js +6 -0
- package/templates/svelte/deps.json +7 -0
- package/templates/svelte/files/index.html +11 -0
- package/templates/svelte/files/janela.conf.json +10 -0
- package/templates/svelte/files/src/App.svelte +33 -0
- package/templates/svelte/files/src/main.js +4 -0
- package/templates/svelte/files/src-host/main.ts +42 -0
- package/templates/svelte/files/svelte.config.js +3 -0
- package/templates/svelte/files/vite.config.js +6 -0
- package/templates/vue/deps.json +4 -0
- package/templates/vue/files/index.html +11 -0
- package/templates/vue/files/janela.conf.json +10 -0
- package/templates/vue/files/src/App.vue +39 -0
- package/templates/vue/files/src/main.js +4 -0
- package/templates/vue/files/src-host/main.ts +42 -0
- package/templates/vue/files/vite.config.js +6 -0
package/runtime/janela.ts
CHANGED
|
@@ -28,12 +28,34 @@ declare function wvTickStart(h: number, intervalMs: number): number;
|
|
|
28
28
|
declare function wvTickStop(h: number): number;
|
|
29
29
|
declare function wvFsRead(h: number, path: string): number;
|
|
30
30
|
declare function wvFsWrite(h: number, path: string, data: string): number;
|
|
31
|
-
declare function
|
|
32
|
-
declare function
|
|
33
|
-
declare function
|
|
31
|
+
declare function wvJobStatus(h: number, id: number): number;
|
|
32
|
+
declare function wvJobSize(h: number, id: number): number;
|
|
33
|
+
declare function wvJobTakeAt(
|
|
34
|
+
h: number,
|
|
35
|
+
id: number,
|
|
36
|
+
offset: number,
|
|
37
|
+
maxBytes: number,
|
|
38
|
+
sink: (text: string) => void,
|
|
39
|
+
): number;
|
|
40
|
+
declare function wvJobFree(h: number, id: number): number;
|
|
41
|
+
declare function wvDialog(
|
|
42
|
+
h: number,
|
|
43
|
+
kind: number,
|
|
44
|
+
flags: number,
|
|
45
|
+
title: string,
|
|
46
|
+
defaultPath: string,
|
|
47
|
+
defaultName: string,
|
|
48
|
+
filters: string,
|
|
49
|
+
): number;
|
|
50
|
+
declare function wvSetFullscreen(h: number, on: number): number;
|
|
34
51
|
|
|
35
|
-
const
|
|
36
|
-
const
|
|
52
|
+
const JOB_PENDING = 0;
|
|
53
|
+
const JOB_OK = 1;
|
|
54
|
+
|
|
55
|
+
const DLG_OPEN = 0;
|
|
56
|
+
const DLG_SAVE = 1;
|
|
57
|
+
const DLG_MULTIPLE = 1;
|
|
58
|
+
const DLG_DIRECTORY = 2;
|
|
37
59
|
|
|
38
60
|
// Injected into every page before it loads (webview_init).
|
|
39
61
|
const BOOTSTRAP =
|
|
@@ -81,6 +103,32 @@ export type AsyncCommandHandler = (
|
|
|
81
103
|
*/
|
|
82
104
|
export type FsCallback = (err: string | null, text: string) => void;
|
|
83
105
|
|
|
106
|
+
/** A named group of extensions offered in a dialog's file-type popup. */
|
|
107
|
+
export interface DialogFilter {
|
|
108
|
+
name: string;
|
|
109
|
+
/** Bare extensions, no dot and no glob: ["png", "jpg"]. */
|
|
110
|
+
extensions: string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface OpenDialogOptions {
|
|
114
|
+
title?: string;
|
|
115
|
+
/** Directory the dialog opens in. */
|
|
116
|
+
defaultPath?: string;
|
|
117
|
+
/** Allow picking more than one entry. */
|
|
118
|
+
multiple?: boolean;
|
|
119
|
+
/** Pick directories instead of files. Not supported on Windows. */
|
|
120
|
+
directory?: boolean;
|
|
121
|
+
filters?: DialogFilter[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SaveDialogOptions {
|
|
125
|
+
title?: string;
|
|
126
|
+
defaultPath?: string;
|
|
127
|
+
/** Filename pre-filled in the name field. */
|
|
128
|
+
defaultName?: string;
|
|
129
|
+
filters?: DialogFilter[];
|
|
130
|
+
}
|
|
131
|
+
|
|
84
132
|
export interface WindowConfig {
|
|
85
133
|
title: string;
|
|
86
134
|
width: number;
|
|
@@ -113,6 +161,30 @@ export interface JanelaApp {
|
|
|
113
161
|
data: string,
|
|
114
162
|
cb: (err: string | null) => void,
|
|
115
163
|
) => void;
|
|
164
|
+
/**
|
|
165
|
+
* Show the native "open" dialog. `cb` gets the chosen paths, or null if the
|
|
166
|
+
* user cancelled. The modal runs on a later turn of the UI thread, so
|
|
167
|
+
* calling this from inside a command does not block that command's reply —
|
|
168
|
+
* pair it with commandAsync when the page is waiting for the result.
|
|
169
|
+
*/
|
|
170
|
+
openFileDialog: (
|
|
171
|
+
options: OpenDialogOptions,
|
|
172
|
+
cb: (paths: string[] | null, err?: string) => void,
|
|
173
|
+
) => void;
|
|
174
|
+
/** Show the native "save" dialog; cb gets the path, or null on cancel. */
|
|
175
|
+
saveFileDialog: (
|
|
176
|
+
options: SaveDialogOptions,
|
|
177
|
+
cb: (path: string | null, err?: string) => void,
|
|
178
|
+
) => void;
|
|
179
|
+
/** Change the window title at any time, not just at startup. */
|
|
180
|
+
setTitle: (title: string) => void;
|
|
181
|
+
/**
|
|
182
|
+
* Resize the window. `hint` is webview's sizing hint: 0 none, 1 minimum,
|
|
183
|
+
* 2 maximum, 3 fixed.
|
|
184
|
+
*/
|
|
185
|
+
setSize: (width: number, height: number, hint?: number) => void;
|
|
186
|
+
/** Enter or leave fullscreen. */
|
|
187
|
+
setFullscreen: (on: boolean) => void;
|
|
116
188
|
/** Fire an event into the page; the payload is delivered as a value. */
|
|
117
189
|
emit: (event: string, payload: unknown) => void;
|
|
118
190
|
/** Close the window and make run() return. */
|
|
@@ -145,23 +217,115 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
145
217
|
let taskFns: (() => void)[] = [];
|
|
146
218
|
let timerFns: (() => void)[] = [];
|
|
147
219
|
let timerDue: number[] = [];
|
|
148
|
-
let
|
|
149
|
-
let
|
|
220
|
+
let jobIds: number[] = [];
|
|
221
|
+
let jobCbs: FsCallback[] = [];
|
|
150
222
|
let ticking = false;
|
|
151
223
|
|
|
224
|
+
// ---- the drain -----------------------------------------------------------
|
|
225
|
+
// A finished job's bytes still have to be decoded into a TypeScript string,
|
|
226
|
+
// and that cost is proportional to the payload: taking a 100 MB file in one
|
|
227
|
+
// call froze the window for ~240 ms. So a finished job moves here and is
|
|
228
|
+
// decoded a slice at a time, giving the run loop the thread back between
|
|
229
|
+
// slices — total work is unchanged, but no single turn carries much of it.
|
|
230
|
+
//
|
|
231
|
+
// The budget is wall-clock rather than a byte count on purpose: a fixed
|
|
232
|
+
// chunk size fixes the WORST turn but also caps throughput (128 KB per 8 ms
|
|
233
|
+
// tick would cap reads at ~16 MB/s), whereas a time budget spends whatever
|
|
234
|
+
// the machine can do in the time available.
|
|
235
|
+
const DRAIN_BUDGET_MS = 4; // ≈ a quarter of a 60fps frame
|
|
236
|
+
const DRAIN_SLICE = 131072; // 128 KB — granularity within the budget
|
|
237
|
+
let drainIds: number[] = [];
|
|
238
|
+
let drainCbs: FsCallback[] = [];
|
|
239
|
+
let drainOk: boolean[] = [];
|
|
240
|
+
let drainParts: string[][] = [];
|
|
241
|
+
let drainOff: number[] = [];
|
|
242
|
+
let drainSize: number[] = [];
|
|
243
|
+
|
|
244
|
+
// Tick interval: 8 ms is plenty for timers and task chains, but while a
|
|
245
|
+
// payload is draining the loop is doing real work every turn, and waiting
|
|
246
|
+
// 8 ms between 4 ms slices would halve throughput for no benefit. So the
|
|
247
|
+
// ticker runs tighter for as long as there is a payload in flight.
|
|
248
|
+
const TICK_IDLE_MS = 8;
|
|
249
|
+
const TICK_DRAIN_MS = 4;
|
|
250
|
+
let tickMs = TICK_IDLE_MS;
|
|
251
|
+
|
|
252
|
+
const retick = (): void => {
|
|
253
|
+
const want = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
|
|
254
|
+
if (!ticking || want === tickMs) return;
|
|
255
|
+
tickMs = want;
|
|
256
|
+
wvTickStart(h, want);
|
|
257
|
+
};
|
|
258
|
+
|
|
152
259
|
const wake = (): void => {
|
|
153
260
|
if (ticking) return;
|
|
154
261
|
ticking = true;
|
|
155
|
-
|
|
262
|
+
tickMs = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
|
|
263
|
+
wvTickStart(h, tickMs);
|
|
156
264
|
};
|
|
157
265
|
|
|
158
266
|
const idle = (): void => {
|
|
159
267
|
if (!ticking) return;
|
|
160
|
-
if (
|
|
268
|
+
if (
|
|
269
|
+
taskFns.length > 0 ||
|
|
270
|
+
timerFns.length > 0 ||
|
|
271
|
+
jobIds.length > 0 ||
|
|
272
|
+
drainIds.length > 0
|
|
273
|
+
) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
161
276
|
ticking = false;
|
|
162
277
|
wvTickStop(h);
|
|
163
278
|
};
|
|
164
279
|
|
|
280
|
+
// Decode as much of the pending payloads as the budget allows, then yield.
|
|
281
|
+
// Slices are taken from one job at a time so a big read finishes promptly
|
|
282
|
+
// rather than every concurrent read finishing slowly.
|
|
283
|
+
const drainSome = (): void => {
|
|
284
|
+
if (drainIds.length === 0) return;
|
|
285
|
+
const started = Date.now() + 0;
|
|
286
|
+
|
|
287
|
+
while (drainIds.length > 0) {
|
|
288
|
+
let chunk = "";
|
|
289
|
+
const taken =
|
|
290
|
+
wvJobTakeAt(h, drainIds[0], drainOff[0], DRAIN_SLICE, (text) => {
|
|
291
|
+
chunk = text;
|
|
292
|
+
}) + 0;
|
|
293
|
+
|
|
294
|
+
// A negative count means the job vanished; treat the payload as final
|
|
295
|
+
// rather than spinning on it forever.
|
|
296
|
+
if (taken > 0) {
|
|
297
|
+
drainParts[0].push(chunk);
|
|
298
|
+
drainOff[0] = drainOff[0] + taken;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (taken <= 0 || drainOff[0] >= drainSize[0]) {
|
|
302
|
+
// Joining is one unavoidable O(n) copy: the callback is handed a
|
|
303
|
+
// single string, so the whole payload must be materialised once.
|
|
304
|
+
const payload = drainParts[0].join("");
|
|
305
|
+
const cb = drainCbs[0];
|
|
306
|
+
const ok = drainOk[0];
|
|
307
|
+
wvJobFree(h, drainIds[0]);
|
|
308
|
+
|
|
309
|
+
drainIds = drainIds.slice(1);
|
|
310
|
+
drainCbs = drainCbs.slice(1);
|
|
311
|
+
drainOk = drainOk.slice(1);
|
|
312
|
+
drainParts = drainParts.slice(1);
|
|
313
|
+
drainOff = drainOff.slice(1);
|
|
314
|
+
drainSize = drainSize.slice(1);
|
|
315
|
+
|
|
316
|
+
if (ok) {
|
|
317
|
+
cb(null, payload);
|
|
318
|
+
} else {
|
|
319
|
+
cb(payload, "");
|
|
320
|
+
}
|
|
321
|
+
// User code just ran and may have taken a while; re-check the budget
|
|
322
|
+
// before starting another payload.
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (Date.now() - started >= DRAIN_BUDGET_MS) return;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
165
329
|
// One turn of the loop: every task queued so far, plus every due timer.
|
|
166
330
|
// Tasks queued *by* this turn wait for the next one, so a defer() chain
|
|
167
331
|
// yields to the UI between slices instead of starving it.
|
|
@@ -190,44 +354,92 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
190
354
|
|
|
191
355
|
// Finished file jobs: the worker thread has already done the blocking
|
|
192
356
|
// syscall, so all that happens on this (UI) thread is the drain.
|
|
193
|
-
if (
|
|
357
|
+
if (jobIds.length > 0) {
|
|
194
358
|
const keptIds: number[] = [];
|
|
195
359
|
const keptCbs: FsCallback[] = [];
|
|
196
360
|
const doneIds: number[] = [];
|
|
197
361
|
const doneCbs: FsCallback[] = [];
|
|
198
362
|
const doneOk: boolean[] = [];
|
|
199
|
-
for (let i = 0; i <
|
|
200
|
-
const st =
|
|
201
|
-
if (st ===
|
|
202
|
-
keptIds.push(
|
|
203
|
-
keptCbs.push(
|
|
363
|
+
for (let i = 0; i < jobIds.length; i++) {
|
|
364
|
+
const st = wvJobStatus(h, jobIds[i]) + 0;
|
|
365
|
+
if (st === JOB_PENDING) {
|
|
366
|
+
keptIds.push(jobIds[i]);
|
|
367
|
+
keptCbs.push(jobCbs[i]);
|
|
204
368
|
} else {
|
|
205
|
-
doneIds.push(
|
|
206
|
-
doneCbs.push(
|
|
207
|
-
doneOk.push(st ===
|
|
369
|
+
doneIds.push(jobIds[i]);
|
|
370
|
+
doneCbs.push(jobCbs[i]);
|
|
371
|
+
doneOk.push(st === JOB_OK);
|
|
208
372
|
}
|
|
209
373
|
}
|
|
210
|
-
|
|
211
|
-
|
|
374
|
+
jobIds = keptIds;
|
|
375
|
+
jobCbs = keptCbs;
|
|
212
376
|
for (let i = 0; i < doneIds.length; i++) {
|
|
213
|
-
// On failure the payload IS the error message, so one
|
|
214
|
-
// outcomes.
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
doneCbs[i](null, payload);
|
|
223
|
-
} else {
|
|
224
|
-
doneCbs[i](payload, "");
|
|
225
|
-
}
|
|
377
|
+
// On failure the payload IS the error message, so one path serves both
|
|
378
|
+
// outcomes. Nothing is decoded here: the job joins the drain queue and
|
|
379
|
+
// its bytes are taken a slice at a time, under a time budget.
|
|
380
|
+
drainIds.push(doneIds[i]);
|
|
381
|
+
drainCbs.push(doneCbs[i]);
|
|
382
|
+
drainOk.push(doneOk[i]);
|
|
383
|
+
drainParts.push([]);
|
|
384
|
+
drainOff.push(0);
|
|
385
|
+
drainSize.push(wvJobSize(h, doneIds[i]) + 0);
|
|
226
386
|
}
|
|
227
387
|
}
|
|
388
|
+
|
|
389
|
+
drainSome();
|
|
390
|
+
retick();
|
|
228
391
|
idle();
|
|
229
392
|
};
|
|
230
393
|
|
|
394
|
+
// Filters cross as "Name|ext,ext|Name|ext" — the shim needs no JSON parser
|
|
395
|
+
// for what is always a short, flat list.
|
|
396
|
+
const encodeFilters = (filters: DialogFilter[] | undefined): string => {
|
|
397
|
+
if (filters === undefined || filters.length === 0) return "";
|
|
398
|
+
const parts: string[] = [];
|
|
399
|
+
for (let i = 0; i < filters.length; i++) {
|
|
400
|
+
parts.push(filters[i].name);
|
|
401
|
+
parts.push(filters[i].extensions.join(","));
|
|
402
|
+
}
|
|
403
|
+
return parts.join("|");
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// Both dialog kinds share one path: start the job, then let the same drain
|
|
407
|
+
// that serves file I/O deliver the answer on a later turn.
|
|
408
|
+
const startDialog = (
|
|
409
|
+
kind: number,
|
|
410
|
+
flags: number,
|
|
411
|
+
title: string | undefined,
|
|
412
|
+
defaultPath: string | undefined,
|
|
413
|
+
defaultName: string | undefined,
|
|
414
|
+
filters: DialogFilter[] | undefined,
|
|
415
|
+
cb: (paths: string[] | null, err?: string) => void,
|
|
416
|
+
): void => {
|
|
417
|
+
const id = wvDialog(
|
|
418
|
+
h,
|
|
419
|
+
kind,
|
|
420
|
+
flags,
|
|
421
|
+
title === undefined ? "" : title,
|
|
422
|
+
defaultPath === undefined ? "" : defaultPath,
|
|
423
|
+
defaultName === undefined ? "" : defaultName,
|
|
424
|
+
encodeFilters(filters),
|
|
425
|
+
) + 0;
|
|
426
|
+
if (id < 0) {
|
|
427
|
+
taskFns.push(() => cb(null, "EAGAIN: could not open a dialog"));
|
|
428
|
+
wake();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
jobIds.push(id);
|
|
432
|
+
jobCbs.push((err, text) => {
|
|
433
|
+
if (err !== null) {
|
|
434
|
+
cb(null, err);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
// "null" is a cancel; anything else is a JSON array of paths.
|
|
438
|
+
cb(JSON.parse(text) as string[] | null);
|
|
439
|
+
});
|
|
440
|
+
wake();
|
|
441
|
+
};
|
|
442
|
+
|
|
231
443
|
const app: JanelaApp = {
|
|
232
444
|
handle: h,
|
|
233
445
|
names: [],
|
|
@@ -260,8 +472,8 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
260
472
|
app.defer(() => cb("EAGAIN: could not start a read of '" + path + "'", ""));
|
|
261
473
|
return;
|
|
262
474
|
}
|
|
263
|
-
|
|
264
|
-
|
|
475
|
+
jobIds.push(id);
|
|
476
|
+
jobCbs.push(cb);
|
|
265
477
|
wake();
|
|
266
478
|
},
|
|
267
479
|
|
|
@@ -271,13 +483,44 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
271
483
|
app.defer(() => cb("EAGAIN: could not start a write of '" + path + "'"));
|
|
272
484
|
return;
|
|
273
485
|
}
|
|
274
|
-
|
|
486
|
+
jobIds.push(id);
|
|
275
487
|
// The write payload is empty on success; the shared callback shape just
|
|
276
488
|
// ignores the text argument.
|
|
277
|
-
|
|
489
|
+
jobCbs.push((err, _text) => cb(err));
|
|
278
490
|
wake();
|
|
279
491
|
},
|
|
280
492
|
|
|
493
|
+
openFileDialog: (options, cb) => {
|
|
494
|
+
let flags = 0;
|
|
495
|
+
if (options.multiple === true) flags = flags + DLG_MULTIPLE;
|
|
496
|
+
if (options.directory === true) flags = flags + DLG_DIRECTORY;
|
|
497
|
+
startDialog(DLG_OPEN, flags, options.title, options.defaultPath, "",
|
|
498
|
+
options.filters, (paths, err) => cb(paths, err));
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
saveFileDialog: (options, cb) => {
|
|
502
|
+
startDialog(DLG_SAVE, 0, options.title, options.defaultPath,
|
|
503
|
+
options.defaultName, options.filters, (paths, err) => {
|
|
504
|
+
if (paths === null) {
|
|
505
|
+
cb(null, err);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
cb(paths.length > 0 ? paths[0] : null, err);
|
|
509
|
+
});
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
setTitle: (title) => {
|
|
513
|
+
wvSetTitle(h, title);
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
setSize: (width, height, hint) => {
|
|
517
|
+
wvSetSize(h, width, height, hint === undefined ? 0 : hint);
|
|
518
|
+
},
|
|
519
|
+
|
|
520
|
+
setFullscreen: (on) => {
|
|
521
|
+
wvSetFullscreen(h, on ? 1 : 0);
|
|
522
|
+
},
|
|
523
|
+
|
|
281
524
|
emit: (event, payload) => {
|
|
282
525
|
wvEval(
|
|
283
526
|
h,
|