janela 0.3.0 → 0.4.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 +77 -11
- package/api/global.d.ts +27 -0
- package/api/index.d.ts +37 -0
- package/api/index.js +35 -0
- package/bin/janela.mjs +21 -4
- package/package.json +15 -1
- package/runtime/janela.ts +138 -134
- package/runtime/types.ts +131 -0
- package/shim/wvshim.cc +55 -8
- package/templates/index.html +4 -2
- package/templates/main.ts +1 -1
- package/templates/react/deps.json +14 -2
- package/templates/react/files/index.html +1 -1
- package/templates/react/files/src/{App.jsx → App.tsx} +19 -8
- package/templates/react/files/src/main.tsx +12 -0
- package/templates/react/files/src-host/main.ts +1 -1
- package/templates/react/files/tsconfig.json +25 -0
- package/templates/solid/deps.json +11 -2
- package/templates/solid/files/index.html +1 -1
- package/templates/solid/files/src/App.tsx +50 -0
- package/templates/solid/files/src/main.tsx +7 -0
- package/templates/solid/files/src-host/main.ts +1 -1
- package/templates/solid/files/tsconfig.json +26 -0
- package/templates/svelte/deps.json +6 -1
- package/templates/svelte/files/index.html +1 -1
- package/templates/svelte/files/src/App.svelte +10 -7
- package/templates/svelte/files/src/main.ts +7 -0
- package/templates/svelte/files/src-host/main.ts +1 -1
- package/templates/svelte/files/tsconfig.json +23 -0
- package/templates/vue/deps.json +12 -2
- package/templates/vue/files/index.html +1 -1
- package/templates/vue/files/src/App.vue +9 -7
- package/templates/vue/files/src-host/main.ts +1 -1
- package/templates/vue/files/tsconfig.json +23 -0
- package/templates/react/files/src/main.jsx +0 -9
- package/templates/solid/files/src/App.jsx +0 -35
- package/templates/solid/files/src/main.jsx +0 -4
- package/templates/svelte/files/src/main.js +0 -4
- /package/templates/vue/files/src/{main.js → main.ts} +0 -0
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ MSI.
|
|
|
70
70
|
|
|
71
71
|
```
|
|
72
72
|
my-app/
|
|
73
|
-
├── index.html frontend — any HTML/JS; calls
|
|
73
|
+
├── index.html frontend — any HTML/JS/TS; calls invoke() / listen()
|
|
74
74
|
├── src-host/main.ts backend — exports setup(app), registers commands
|
|
75
75
|
└── janela.conf.json name, bundle identifier, version, window
|
|
76
76
|
```
|
|
@@ -79,17 +79,42 @@ A Vite project adds a `vite.config.js` and a `src/` tree — that config is what
|
|
|
79
79
|
makes janela build the frontend with Vite instead of inlining `index.html`
|
|
80
80
|
directly.
|
|
81
81
|
|
|
82
|
-
Frontend API
|
|
82
|
+
Frontend API — import it, and your editor and `tsc` know the shapes:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { invoke, listen } from "janela/api";
|
|
86
|
+
|
|
87
|
+
const sum = await invoke<number>("add", { a: 2, b: 40 }); // call a backend command
|
|
88
|
+
listen<number>("added", (payload) => { ... }); // backend-fired events
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`janela` is already a devDependency of a scaffolded project, so there is
|
|
92
|
+
nothing extra to install. The generic is what the host command returns —
|
|
93
|
+
values cross the boundary as values, so there is no JSON to parse.
|
|
94
|
+
|
|
95
|
+
<details>
|
|
96
|
+
<summary>No bundler? Use the injected global instead</summary>
|
|
97
|
+
|
|
98
|
+
janela injects the same two functions as `window.janela` before every document
|
|
99
|
+
loads, which is what the `vanilla` template uses — it needs no `npm install` at
|
|
100
|
+
all:
|
|
83
101
|
|
|
84
102
|
```js
|
|
85
|
-
const sum = await janela.invoke("add", { a: 2, b: 40 });
|
|
86
|
-
janela.listen("added", (payload) => { ... });
|
|
103
|
+
const sum = await janela.invoke("add", { a: 2, b: 40 });
|
|
104
|
+
janela.listen("added", (payload) => { ... });
|
|
87
105
|
```
|
|
88
106
|
|
|
89
|
-
|
|
107
|
+
TypeScript users on this path can pull in the ambient types with
|
|
108
|
+
`/// <reference types="janela/global" />`, or by adding `"janela/global"` to
|
|
109
|
+
`compilerOptions.types`. With a bundler, prefer the import — it needs no
|
|
110
|
+
ambient declaration.
|
|
111
|
+
|
|
112
|
+
</details>
|
|
113
|
+
|
|
114
|
+
Backend API (`src-host/main.ts`) — also typed, from the same package:
|
|
90
115
|
|
|
91
116
|
```ts
|
|
92
|
-
import type { JanelaApp } from "
|
|
117
|
+
import type { JanelaApp } from "janela/host";
|
|
93
118
|
|
|
94
119
|
export function setup(app: JanelaApp): void {
|
|
95
120
|
app.command("add", (args) => { // values in, values out
|
|
@@ -154,10 +179,14 @@ Errors arrive as values, never throws — `err` carries a Node-shaped message
|
|
|
154
179
|
(`ENOENT: no such file or directory, open '/x'`). UTF-8 round-trips exactly,
|
|
155
180
|
astral characters included.
|
|
156
181
|
|
|
157
|
-
The payload crosses in a single call (format 3), and the
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
182
|
+
The payload crosses in a single call (format 3), and the decode that follows is
|
|
183
|
+
spread across turns under a 4 ms budget, so a large read no longer stalls the
|
|
184
|
+
window: a 100 MB file's worst UI pause is ~25 ms (p99 4 ms) rather than ~176 ms,
|
|
185
|
+
at the same throughput. The remaining pause is the one unavoidable copy that
|
|
186
|
+
materialises the string for your callback. See
|
|
187
|
+
[docs/async.md](../../docs/async.md) for the measurements — and note that
|
|
188
|
+
indexing a large string in your own callback (`text.length`, `slice`) is O(n)
|
|
189
|
+
in scriptc and can cost far more than the read did.
|
|
161
190
|
|
|
162
191
|
**Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
|
|
163
192
|
as long as the program sits inside the `run()` FFI call, so `setTimeout`,
|
|
@@ -206,11 +235,48 @@ nested modal loop would otherwise re-enter the host loop underneath a live TS
|
|
|
206
235
|
frame; [docs/native-shell.md](../../docs/native-shell.md) has the details, the
|
|
207
236
|
per-platform table, and the Windows GUI-subsystem note.
|
|
208
237
|
|
|
238
|
+
## Migrating from 0.3.x
|
|
239
|
+
|
|
240
|
+
Nothing breaks: the injected `janela` global still works exactly as before.
|
|
241
|
+
What changed is the recommendation — the frontend now has a real module, so
|
|
242
|
+
editors and `tsc` can see it:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// 0.3.x — an untyped global, invisible to tsc and ESLint
|
|
246
|
+
const sum = await janela.invoke("add", { a: 2, b: 40 });
|
|
247
|
+
|
|
248
|
+
// 0.4.x — typed, resolvable, and generic over what the command returns
|
|
249
|
+
import { invoke } from "janela/api";
|
|
250
|
+
const sum = await invoke<number>("add", { a: 2, b: 40 });
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The host side had the same problem and gets the same fix. `src-host/main.ts`
|
|
254
|
+
used to import `JanelaApp` from `"./janela"` — a path that only exists inside
|
|
255
|
+
`.janela/build/`, so an editor could never resolve it and the whole `app.*`
|
|
256
|
+
API was untyped:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
// 0.3.x — unresolved in the editor; JanelaApp was effectively `any`
|
|
260
|
+
import type { JanelaApp } from "./janela";
|
|
261
|
+
|
|
262
|
+
// 0.4.x — resolves against the installed package
|
|
263
|
+
import type { JanelaApp } from "janela/host";
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`janela build` rewrites that specifier to the local runtime copy while
|
|
267
|
+
assembling the compile unit, so the build stays fully static and a project
|
|
268
|
+
with no `node_modules` at all still compiles.
|
|
269
|
+
|
|
270
|
+
The framework templates (`vue`, `react`, `svelte`, `solid`) are TypeScript now
|
|
271
|
+
and scaffold with a `typecheck` script that covers `src/` and `src-host/`
|
|
272
|
+
alike. `vanilla` stays plain JavaScript on the global, so it still needs no
|
|
273
|
+
`npm install` before the first build.
|
|
274
|
+
|
|
209
275
|
## Migrating from 0.1.x
|
|
210
276
|
|
|
211
277
|
Commands used to take and return **JSON text**; they now take and return
|
|
212
278
|
**values**, with the runtime handling serialisation. The page-side API
|
|
213
|
-
(`
|
|
279
|
+
(`invoke` / `listen`) is unchanged.
|
|
214
280
|
|
|
215
281
|
```ts
|
|
216
282
|
// 0.1.x
|
package/api/global.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the injected `janela` global, for pages that use it directly
|
|
3
|
+
* rather than importing `janela/api` — a plain `<script>` with no bundler,
|
|
4
|
+
* typically. Pull them in from a TypeScript project with:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* /// <reference types="janela/global" />
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* or by adding `"janela/global"` to `compilerOptions.types` in tsconfig.json.
|
|
11
|
+
*
|
|
12
|
+
* If you have a bundler, prefer `import { invoke, listen } from "janela/api"` —
|
|
13
|
+
* it needs no ambient declaration and is what the templates use.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { JanelaBridge } from "./index.js";
|
|
17
|
+
|
|
18
|
+
declare global {
|
|
19
|
+
/** The host bridge janela injects before the document loads. */
|
|
20
|
+
const janela: JanelaBridge;
|
|
21
|
+
|
|
22
|
+
interface Window {
|
|
23
|
+
janela: JanelaBridge;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export {};
|
package/api/index.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The janela frontend API.
|
|
3
|
+
*
|
|
4
|
+
* Values cross the boundary as values — janela owns the JSON at the edge — so
|
|
5
|
+
* the generic parameter is what the host command returns, not a string to
|
|
6
|
+
* parse.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The bridge janela injects as `window.janela` before each document loads. */
|
|
10
|
+
export interface JanelaBridge {
|
|
11
|
+
invoke<T = unknown>(cmd: string, args?: unknown): Promise<T>;
|
|
12
|
+
listen<T = unknown>(event: string, cb: (payload: T) => void): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Call a command the host registered with `app.command` / `app.commandAsync`.
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* const sum = await invoke<number>("add", { a: 2, b: 40 });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Rejects if the command is unknown, if the handler rejected, or if the page
|
|
23
|
+
* is not running inside a janela window.
|
|
24
|
+
*/
|
|
25
|
+
export declare function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Subscribe to an event the host sends with `app.emit`.
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* listen<number>("added", (sum) => console.log(sum));
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare function listen<T = unknown>(
|
|
35
|
+
event: string,
|
|
36
|
+
cb: (payload: T) => void,
|
|
37
|
+
): void;
|
package/api/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The janela frontend API — `import { invoke, listen } from "janela/api"`.
|
|
2
|
+
//
|
|
3
|
+
// janela injects a bridge as `window.janela` into every document before it
|
|
4
|
+
// loads, so this module is a thin wrapper over that global rather than a
|
|
5
|
+
// transport of its own. Importing it is the recommended style: bundlers
|
|
6
|
+
// resolve it, editors complete it, and `tsc` checks it. The global stays
|
|
7
|
+
// available unchanged for pages with no build step.
|
|
8
|
+
|
|
9
|
+
function bridge() {
|
|
10
|
+
const found = typeof globalThis === "undefined" ? undefined : globalThis.janela;
|
|
11
|
+
if (!found || typeof found.invoke !== "function") {
|
|
12
|
+
throw new Error(
|
|
13
|
+
"janela: no host bridge on this page (window.janela is undefined). " +
|
|
14
|
+
"The page is not running inside a janela window — run the app with " +
|
|
15
|
+
"`janela dev`, or `janela build` and launch the binary. Opening the " +
|
|
16
|
+
"page in a browser, or serving it with plain `vite`, leaves no host " +
|
|
17
|
+
"to talk to.",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Call a command the host registered with `app.command` / `app.commandAsync`.
|
|
25
|
+
* Arguments and the resolved value are ordinary values; janela owns the
|
|
26
|
+
* serialisation at the boundary.
|
|
27
|
+
*/
|
|
28
|
+
export async function invoke(cmd, args) {
|
|
29
|
+
return bridge().invoke(cmd, args);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Subscribe to an event the host sends with `app.emit`. */
|
|
33
|
+
export function listen(event, cb) {
|
|
34
|
+
bridge().listen(event, cb);
|
|
35
|
+
}
|
package/bin/janela.mjs
CHANGED
|
@@ -345,14 +345,17 @@ function ffiManifest(shimLib) {
|
|
|
345
345
|
// Job accessors, shared by file I/O and dialogs: both are work whose
|
|
346
346
|
// answer cannot be produced during the FFI call that starts it.
|
|
347
347
|
{ name: "wvJobStatus", symbol: "wv_job_status", params: ["i32", "i32"], returns: "i32" },
|
|
348
|
+
{ name: "wvJobSize", symbol: "wv_job_size", params: ["i32", "i32"], returns: "f64" },
|
|
348
349
|
{
|
|
349
|
-
|
|
350
|
+
// One slice per call, so a large payload decodes across several UI turns
|
|
351
|
+
// instead of stalling on all of it at once. Returns the bytes covered.
|
|
352
|
+
name: "wvJobTakeAt", symbol: "wv_job_take_at",
|
|
350
353
|
params: [
|
|
351
|
-
"i32", "i32",
|
|
354
|
+
"i32", "i32", "f64", "f64",
|
|
352
355
|
{ callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
|
|
353
356
|
{ context: "sink" },
|
|
354
357
|
],
|
|
355
|
-
returns: "
|
|
358
|
+
returns: "f64",
|
|
356
359
|
},
|
|
357
360
|
{ name: "wvJobFree", symbol: "wv_job_free", params: ["i32", "i32"], returns: "i32" },
|
|
358
361
|
// Native dialogs: the modal runs on a later UI-thread turn, so asking for
|
|
@@ -478,9 +481,22 @@ function build(root, { devUrl = null, gui = true } = {}) {
|
|
|
478
481
|
|
|
479
482
|
// Assemble the compile unit: runtime + user's commands + generated modules.
|
|
480
483
|
cpSync(join(KIT, "runtime", "janela.ts"), join(buildDir, "janela.ts"));
|
|
484
|
+
cpSync(join(KIT, "runtime", "types.ts"), join(buildDir, "types.ts"));
|
|
481
485
|
const mainSrc = join(root, "src-host", "main.ts");
|
|
482
486
|
if (!existsSync(mainSrc)) fail("missing src-host/main.ts");
|
|
483
|
-
|
|
487
|
+
// A project's main.ts imports from "janela/host" so that it resolves in the
|
|
488
|
+
// editor against the installed package. Here it is compiled next to the
|
|
489
|
+
// runtime instead, so the specifier is rewritten to that local copy: the
|
|
490
|
+
// build never resolves through node_modules, which keeps it static and will
|
|
491
|
+
// keep working when "janela/host" starts exporting values (not just types)
|
|
492
|
+
// as well.
|
|
493
|
+
writeFileSync(
|
|
494
|
+
join(buildDir, "main.ts"),
|
|
495
|
+
readFileSync(mainSrc, "utf8").replace(
|
|
496
|
+
/(\bfrom\s*)(['"])janela\/host\2/g,
|
|
497
|
+
"$1$2./janela$2",
|
|
498
|
+
),
|
|
499
|
+
);
|
|
484
500
|
|
|
485
501
|
const html = frontendHtml(root, conf, devUrl);
|
|
486
502
|
writeFileSync(
|
|
@@ -613,6 +629,7 @@ function init(name, template) {
|
|
|
613
629
|
const extra = JSON.parse(readFileSync(join(tdir, "deps.json"), "utf8"));
|
|
614
630
|
pkg.type = "module";
|
|
615
631
|
Object.assign(pkg.devDependencies, extra.devDependencies ?? {});
|
|
632
|
+
Object.assign(pkg.scripts, extra.scripts ?? {});
|
|
616
633
|
if (extra.dependencies) pkg.dependencies = extra.dependencies;
|
|
617
634
|
}
|
|
618
635
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "janela",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": {
|
|
7
7
|
"janela": "bin/janela.mjs",
|
|
8
8
|
"jn": "bin/janela.mjs"
|
|
9
9
|
},
|
|
10
|
+
"exports": {
|
|
11
|
+
"./api": {
|
|
12
|
+
"types": "./api/index.d.ts",
|
|
13
|
+
"default": "./api/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./host": {
|
|
16
|
+
"types": "./runtime/types.ts"
|
|
17
|
+
},
|
|
18
|
+
"./global": {
|
|
19
|
+
"types": "./api/global.d.ts"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
10
23
|
"dependencies": {
|
|
11
24
|
"scriptc": "0.0.35"
|
|
12
25
|
},
|
|
@@ -33,6 +46,7 @@
|
|
|
33
46
|
"node": ">=24"
|
|
34
47
|
},
|
|
35
48
|
"files": [
|
|
49
|
+
"api/",
|
|
36
50
|
"bin/",
|
|
37
51
|
"runtime/",
|
|
38
52
|
"shim/",
|
package/runtime/janela.ts
CHANGED
|
@@ -29,7 +29,14 @@ 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
31
|
declare function wvJobStatus(h: number, id: number): number;
|
|
32
|
-
declare function
|
|
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;
|
|
33
40
|
declare function wvJobFree(h: number, id: number): number;
|
|
34
41
|
declare function wvDialog(
|
|
35
42
|
h: number,
|
|
@@ -67,124 +74,30 @@ const BOOTSTRAP =
|
|
|
67
74
|
" for (var i = 0; i < cbs.length; i++) cbs[i](payload);" +
|
|
68
75
|
"};";
|
|
69
76
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
* '/x'") and `text` is empty. Errors arrive as values, never as throws —
|
|
95
|
-
* scriptc cannot propagate an exception across the FFI boundary.
|
|
96
|
-
*/
|
|
97
|
-
export type FsCallback = (err: string | null, text: string) => void;
|
|
98
|
-
|
|
99
|
-
/** A named group of extensions offered in a dialog's file-type popup. */
|
|
100
|
-
export interface DialogFilter {
|
|
101
|
-
name: string;
|
|
102
|
-
/** Bare extensions, no dot and no glob: ["png", "jpg"]. */
|
|
103
|
-
extensions: string[];
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export interface OpenDialogOptions {
|
|
107
|
-
title?: string;
|
|
108
|
-
/** Directory the dialog opens in. */
|
|
109
|
-
defaultPath?: string;
|
|
110
|
-
/** Allow picking more than one entry. */
|
|
111
|
-
multiple?: boolean;
|
|
112
|
-
/** Pick directories instead of files. Not supported on Windows. */
|
|
113
|
-
directory?: boolean;
|
|
114
|
-
filters?: DialogFilter[];
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export interface SaveDialogOptions {
|
|
118
|
-
title?: string;
|
|
119
|
-
defaultPath?: string;
|
|
120
|
-
/** Filename pre-filled in the name field. */
|
|
121
|
-
defaultName?: string;
|
|
122
|
-
filters?: DialogFilter[];
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export interface WindowConfig {
|
|
126
|
-
title: string;
|
|
127
|
-
width: number;
|
|
128
|
-
height: number;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export interface JanelaApp {
|
|
132
|
-
handle: number;
|
|
133
|
-
names: string[];
|
|
134
|
-
handlers: CommandHandler[];
|
|
135
|
-
/** Register a named command, callable from the page as janela.invoke(name, args). */
|
|
136
|
-
command: (name: string, h: CommandHandler) => void;
|
|
137
|
-
/** Register a command that answers later; see AsyncCommandHandler. */
|
|
138
|
-
commandAsync: (name: string, h: AsyncCommandHandler) => void;
|
|
139
|
-
/** Run fn on the next turn of the host loop — the way to slice long work. */
|
|
140
|
-
defer: (fn: () => void) => void;
|
|
141
|
-
/** Run fn after at least ms. The host loop's timer; scriptc's setTimeout
|
|
142
|
-
* cannot fire while the window is open (its loop is parked inside run()). */
|
|
143
|
-
sleep: (ms: number, fn: () => void) => void;
|
|
144
|
-
/**
|
|
145
|
-
* Read a file without blocking the window. The syscall runs on a shim
|
|
146
|
-
* worker thread; the callback lands on the UI thread on a later turn.
|
|
147
|
-
* Prefer this over node:fs readFileSync inside a command — that one blocks
|
|
148
|
-
* the loop, and with it the whole window.
|
|
149
|
-
*/
|
|
150
|
-
readFileAsync: (path: string, cb: FsCallback) => void;
|
|
151
|
-
/** Write a file without blocking the window; cb(null) on success. */
|
|
152
|
-
writeFileAsync: (
|
|
153
|
-
path: string,
|
|
154
|
-
data: string,
|
|
155
|
-
cb: (err: string | null) => void,
|
|
156
|
-
) => void;
|
|
157
|
-
/**
|
|
158
|
-
* Show the native "open" dialog. `cb` gets the chosen paths, or null if the
|
|
159
|
-
* user cancelled. The modal runs on a later turn of the UI thread, so
|
|
160
|
-
* calling this from inside a command does not block that command's reply —
|
|
161
|
-
* pair it with commandAsync when the page is waiting for the result.
|
|
162
|
-
*/
|
|
163
|
-
openFileDialog: (
|
|
164
|
-
options: OpenDialogOptions,
|
|
165
|
-
cb: (paths: string[] | null, err?: string) => void,
|
|
166
|
-
) => void;
|
|
167
|
-
/** Show the native "save" dialog; cb gets the path, or null on cancel. */
|
|
168
|
-
saveFileDialog: (
|
|
169
|
-
options: SaveDialogOptions,
|
|
170
|
-
cb: (path: string | null, err?: string) => void,
|
|
171
|
-
) => void;
|
|
172
|
-
/** Change the window title at any time, not just at startup. */
|
|
173
|
-
setTitle: (title: string) => void;
|
|
174
|
-
/**
|
|
175
|
-
* Resize the window. `hint` is webview's sizing hint: 0 none, 1 minimum,
|
|
176
|
-
* 2 maximum, 3 fixed.
|
|
177
|
-
*/
|
|
178
|
-
setSize: (width: number, height: number, hint?: number) => void;
|
|
179
|
-
/** Enter or leave fullscreen. */
|
|
180
|
-
setFullscreen: (on: boolean) => void;
|
|
181
|
-
/** Fire an event into the page; the payload is delivered as a value. */
|
|
182
|
-
emit: (event: string, payload: unknown) => void;
|
|
183
|
-
/** Close the window and make run() return. */
|
|
184
|
-
quit: () => void;
|
|
185
|
-
/** Show the page and block until the window closes. Returns the run status. */
|
|
186
|
-
run: (html: string) => number;
|
|
187
|
-
}
|
|
77
|
+
// The public host types live in ./types (shipped as `janela/host` too, so a
|
|
78
|
+
// user's editor can see them). Re-exported here because the compiled build
|
|
79
|
+
// resolves them through this module — see the specifier rewrite in the CLI.
|
|
80
|
+
export type {
|
|
81
|
+
AsyncCommandHandler,
|
|
82
|
+
CommandHandler,
|
|
83
|
+
DialogFilter,
|
|
84
|
+
FsCallback,
|
|
85
|
+
JanelaApp,
|
|
86
|
+
OpenDialogOptions,
|
|
87
|
+
SaveDialogOptions,
|
|
88
|
+
WindowConfig,
|
|
89
|
+
} from "./types";
|
|
90
|
+
|
|
91
|
+
import type {
|
|
92
|
+
AsyncCommandHandler,
|
|
93
|
+
CommandHandler,
|
|
94
|
+
DialogFilter,
|
|
95
|
+
FsCallback,
|
|
96
|
+
JanelaApp,
|
|
97
|
+
OpenDialogOptions,
|
|
98
|
+
SaveDialogOptions,
|
|
99
|
+
WindowConfig,
|
|
100
|
+
} from "./types";
|
|
188
101
|
|
|
189
102
|
// JSON.stringify yields undefined for undefined; the wire always needs a
|
|
190
103
|
// value, and a command that returns nothing should read as null in the page.
|
|
@@ -214,19 +127,111 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
214
127
|
let jobCbs: FsCallback[] = [];
|
|
215
128
|
let ticking = false;
|
|
216
129
|
|
|
130
|
+
// ---- the drain -----------------------------------------------------------
|
|
131
|
+
// A finished job's bytes still have to be decoded into a TypeScript string,
|
|
132
|
+
// and that cost is proportional to the payload: taking a 100 MB file in one
|
|
133
|
+
// call froze the window for ~240 ms. So a finished job moves here and is
|
|
134
|
+
// decoded a slice at a time, giving the run loop the thread back between
|
|
135
|
+
// slices — total work is unchanged, but no single turn carries much of it.
|
|
136
|
+
//
|
|
137
|
+
// The budget is wall-clock rather than a byte count on purpose: a fixed
|
|
138
|
+
// chunk size fixes the WORST turn but also caps throughput (128 KB per 8 ms
|
|
139
|
+
// tick would cap reads at ~16 MB/s), whereas a time budget spends whatever
|
|
140
|
+
// the machine can do in the time available.
|
|
141
|
+
const DRAIN_BUDGET_MS = 4; // ≈ a quarter of a 60fps frame
|
|
142
|
+
const DRAIN_SLICE = 131072; // 128 KB — granularity within the budget
|
|
143
|
+
let drainIds: number[] = [];
|
|
144
|
+
let drainCbs: FsCallback[] = [];
|
|
145
|
+
let drainOk: boolean[] = [];
|
|
146
|
+
let drainParts: string[][] = [];
|
|
147
|
+
let drainOff: number[] = [];
|
|
148
|
+
let drainSize: number[] = [];
|
|
149
|
+
|
|
150
|
+
// Tick interval: 8 ms is plenty for timers and task chains, but while a
|
|
151
|
+
// payload is draining the loop is doing real work every turn, and waiting
|
|
152
|
+
// 8 ms between 4 ms slices would halve throughput for no benefit. So the
|
|
153
|
+
// ticker runs tighter for as long as there is a payload in flight.
|
|
154
|
+
const TICK_IDLE_MS = 8;
|
|
155
|
+
const TICK_DRAIN_MS = 4;
|
|
156
|
+
let tickMs = TICK_IDLE_MS;
|
|
157
|
+
|
|
158
|
+
const retick = (): void => {
|
|
159
|
+
const want = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
|
|
160
|
+
if (!ticking || want === tickMs) return;
|
|
161
|
+
tickMs = want;
|
|
162
|
+
wvTickStart(h, want);
|
|
163
|
+
};
|
|
164
|
+
|
|
217
165
|
const wake = (): void => {
|
|
218
166
|
if (ticking) return;
|
|
219
167
|
ticking = true;
|
|
220
|
-
|
|
168
|
+
tickMs = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
|
|
169
|
+
wvTickStart(h, tickMs);
|
|
221
170
|
};
|
|
222
171
|
|
|
223
172
|
const idle = (): void => {
|
|
224
173
|
if (!ticking) return;
|
|
225
|
-
if (
|
|
174
|
+
if (
|
|
175
|
+
taskFns.length > 0 ||
|
|
176
|
+
timerFns.length > 0 ||
|
|
177
|
+
jobIds.length > 0 ||
|
|
178
|
+
drainIds.length > 0
|
|
179
|
+
) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
226
182
|
ticking = false;
|
|
227
183
|
wvTickStop(h);
|
|
228
184
|
};
|
|
229
185
|
|
|
186
|
+
// Decode as much of the pending payloads as the budget allows, then yield.
|
|
187
|
+
// Slices are taken from one job at a time so a big read finishes promptly
|
|
188
|
+
// rather than every concurrent read finishing slowly.
|
|
189
|
+
const drainSome = (): void => {
|
|
190
|
+
if (drainIds.length === 0) return;
|
|
191
|
+
const started = Date.now() + 0;
|
|
192
|
+
|
|
193
|
+
while (drainIds.length > 0) {
|
|
194
|
+
let chunk = "";
|
|
195
|
+
const taken =
|
|
196
|
+
wvJobTakeAt(h, drainIds[0], drainOff[0], DRAIN_SLICE, (text) => {
|
|
197
|
+
chunk = text;
|
|
198
|
+
}) + 0;
|
|
199
|
+
|
|
200
|
+
// A negative count means the job vanished; treat the payload as final
|
|
201
|
+
// rather than spinning on it forever.
|
|
202
|
+
if (taken > 0) {
|
|
203
|
+
drainParts[0].push(chunk);
|
|
204
|
+
drainOff[0] = drainOff[0] + taken;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (taken <= 0 || drainOff[0] >= drainSize[0]) {
|
|
208
|
+
// Joining is one unavoidable O(n) copy: the callback is handed a
|
|
209
|
+
// single string, so the whole payload must be materialised once.
|
|
210
|
+
const payload = drainParts[0].join("");
|
|
211
|
+
const cb = drainCbs[0];
|
|
212
|
+
const ok = drainOk[0];
|
|
213
|
+
wvJobFree(h, drainIds[0]);
|
|
214
|
+
|
|
215
|
+
drainIds = drainIds.slice(1);
|
|
216
|
+
drainCbs = drainCbs.slice(1);
|
|
217
|
+
drainOk = drainOk.slice(1);
|
|
218
|
+
drainParts = drainParts.slice(1);
|
|
219
|
+
drainOff = drainOff.slice(1);
|
|
220
|
+
drainSize = drainSize.slice(1);
|
|
221
|
+
|
|
222
|
+
if (ok) {
|
|
223
|
+
cb(null, payload);
|
|
224
|
+
} else {
|
|
225
|
+
cb(payload, "");
|
|
226
|
+
}
|
|
227
|
+
// User code just ran and may have taken a while; re-check the budget
|
|
228
|
+
// before starting another payload.
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (Date.now() - started >= DRAIN_BUDGET_MS) return;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
230
235
|
// One turn of the loop: every task queued so far, plus every due timer.
|
|
231
236
|
// Tasks queued *by* this turn wait for the next one, so a defer() chain
|
|
232
237
|
// yields to the UI between slices instead of starving it.
|
|
@@ -275,21 +280,20 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
275
280
|
jobIds = keptIds;
|
|
276
281
|
jobCbs = keptCbs;
|
|
277
282
|
for (let i = 0; i < doneIds.length; i++) {
|
|
278
|
-
// On failure the payload IS the error message, so one
|
|
279
|
-
// outcomes.
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
doneCbs[i](null, payload);
|
|
288
|
-
} else {
|
|
289
|
-
doneCbs[i](payload, "");
|
|
290
|
-
}
|
|
283
|
+
// On failure the payload IS the error message, so one path serves both
|
|
284
|
+
// outcomes. Nothing is decoded here: the job joins the drain queue and
|
|
285
|
+
// its bytes are taken a slice at a time, under a time budget.
|
|
286
|
+
drainIds.push(doneIds[i]);
|
|
287
|
+
drainCbs.push(doneCbs[i]);
|
|
288
|
+
drainOk.push(doneOk[i]);
|
|
289
|
+
drainParts.push([]);
|
|
290
|
+
drainOff.push(0);
|
|
291
|
+
drainSize.push(wvJobSize(h, doneIds[i]) + 0);
|
|
291
292
|
}
|
|
292
293
|
}
|
|
294
|
+
|
|
295
|
+
drainSome();
|
|
296
|
+
retick();
|
|
293
297
|
idle();
|
|
294
298
|
};
|
|
295
299
|
|