janela 0.1.0 → 0.1.2
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 +92 -5
- package/bin/janela.mjs +118 -3
- package/package.json +2 -2
- package/runtime/janela.ts +125 -0
- package/shim/wvshim.cc +131 -1
- package/templates/index.html +10 -0
- package/templates/main.ts +9 -0
package/README.md
CHANGED
|
@@ -19,8 +19,33 @@ janela build # .janela/out/my-app (+ my-app.app on macOS)
|
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
Requirements: Node 18+, a C++ compiler (Xcode CLT on macOS; g++ +
|
|
22
|
-
`libwebkit2gtk-4.1-dev` on Linux). A worked
|
|
23
|
-
[`examples/demo`](examples/demo) — commands, events, and a
|
|
22
|
+
`libwebkit2gtk-4.1-dev` on Linux; see [Windows](#windows) below). A worked
|
|
23
|
+
example lives in [`examples/demo`](examples/demo) — commands, events, and a
|
|
24
|
+
file reader.
|
|
25
|
+
|
|
26
|
+
## Windows
|
|
27
|
+
|
|
28
|
+
`janela build` produces `.janela/out/<name>.exe` using the WebView2 backend.
|
|
29
|
+
|
|
30
|
+
**You need a MinGW-targeting clang on `PATH`** — [llvm-mingw][llvm-mingw],
|
|
31
|
+
MSYS2's `clang64` toolchain, or WinLibs. MSVC does **not** work: scriptc's
|
|
32
|
+
runtime uses POSIX types and calls (`ssize_t`, `nanosleep`, `clock_gettime`)
|
|
33
|
+
that the MSVC CRT does not provide. janela checks `clang -dumpmachine` and
|
|
34
|
+
tells you if the wrong one is first on `PATH`.
|
|
35
|
+
|
|
36
|
+
webview.h's Win32 backend includes `WebView2.h`, which Microsoft ships in a
|
|
37
|
+
nuget package rather than in the Windows SDK, so the first build downloads
|
|
38
|
+
`Microsoft.Web.WebView2` into `.janela/cache/` automatically. To build offline
|
|
39
|
+
or pin your own copy, point `JANELA_WEBVIEW2_INCLUDE` at a directory
|
|
40
|
+
containing `WebView2.h`. No `WebView2Loader.dll` is needed — webview.h has its
|
|
41
|
+
own loader — and end users need only the **WebView2 runtime**, which is
|
|
42
|
+
preinstalled on current Windows 10/11 (it ships with Edge).
|
|
43
|
+
|
|
44
|
+
Two caveats today: the binary is a console-subsystem app, so a console window
|
|
45
|
+
appears behind the UI (handy for `janela dev`, wrong for shipping), and there
|
|
46
|
+
is no installer step — you get a bare `.exe`, not an MSI.
|
|
47
|
+
|
|
48
|
+
[llvm-mingw]: https://github.com/mstorsjo/llvm-mingw/releases
|
|
24
49
|
|
|
25
50
|
## A project
|
|
26
51
|
|
|
@@ -53,6 +78,48 @@ export function setup(app: JanelaApp): void {
|
|
|
53
78
|
}
|
|
54
79
|
```
|
|
55
80
|
|
|
81
|
+
## Async commands
|
|
82
|
+
|
|
83
|
+
A command that has to wait — or to chew through real work — should not freeze
|
|
84
|
+
the window. Register it with `commandAsync` and answer whenever you are ready;
|
|
85
|
+
the page keeps using the same `await janela.invoke(...)`.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
app.commandAsync("wait", (argsJson, resolve, reject) => {
|
|
89
|
+
const a = JSON.parse(argsJson) as { ms: number };
|
|
90
|
+
app.sleep(a.ms, () => resolve(JSON.stringify("done"))); // resolve later
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Work that cannot just wait: slice it, yielding to the UI between slices.
|
|
94
|
+
app.commandAsync("countTo", (argsJson, resolve) => {
|
|
95
|
+
const a = JSON.parse(argsJson) as { n: number };
|
|
96
|
+
let i = 0;
|
|
97
|
+
const step = (): void => {
|
|
98
|
+
const end = Math.min(i + 2_000_000, a.n);
|
|
99
|
+
for (; i < end; i++) { /* ... */ }
|
|
100
|
+
if (i < a.n) app.defer(step); else resolve(JSON.stringify(i));
|
|
101
|
+
};
|
|
102
|
+
app.defer(step);
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- `app.defer(fn)` — run `fn` on the next turn of the host loop.
|
|
107
|
+
- `app.sleep(ms, fn)` — run `fn` after at least `ms`.
|
|
108
|
+
- `resolve(json)` / `reject(json)` settle the page's promise; `reject` makes
|
|
109
|
+
`await janela.invoke(...)` throw. Settling twice is ignored.
|
|
110
|
+
|
|
111
|
+
**Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
|
|
112
|
+
as long as the program sits inside the `run()` FFI call, so `setTimeout`,
|
|
113
|
+
`queueMicrotask` and `await` in host code never fire while the window is open
|
|
114
|
+
(they all run after it closes). janela supplies its own loop instead: a native
|
|
115
|
+
ticker posts work to the UI thread via `webview_dispatch`, and it only runs
|
|
116
|
+
while something is queued, so an idle app costs nothing.
|
|
117
|
+
|
|
118
|
+
**Still single-threaded.** scriptc's runtime is not thread-safe (concurrent
|
|
119
|
+
calls from several threads abort the process), so host code always runs on the
|
|
120
|
+
UI thread. Async here means *interleaved*, not parallel: a handler that blocks
|
|
121
|
+
without yielding still freezes the window. Slice long work with `defer`.
|
|
122
|
+
|
|
56
123
|
The backend is ordinary TypeScript with scriptc's stdlib — including a
|
|
57
124
|
`node:fs` subset — so "read a file" or "call an API" is just code in a
|
|
58
125
|
command handler, no plugin layer needed.
|
|
@@ -76,8 +143,9 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
|
|
|
76
143
|
RHS — it is silently miscompiled. Wrap it in any expression (`+ 0`). Plain
|
|
77
144
|
TypeScript is unaffected; only the runtime does FFI, so app code rarely
|
|
78
145
|
meets this.
|
|
79
|
-
- One window per app for now
|
|
80
|
-
command blocks the UI
|
|
146
|
+
- One window per app for now. Host code is single-threaded: a synchronous
|
|
147
|
+
command blocks the UI while it runs — use `commandAsync` + `defer`/`sleep`
|
|
148
|
+
(see "Async commands") for anything slow.
|
|
81
149
|
- `console.log` from commands goes to stdout — visible under `janela dev`,
|
|
82
150
|
not when launched from Finder.
|
|
83
151
|
|
|
@@ -86,9 +154,28 @@ no `-framework` support) and the binary is wrapped into an ad-hoc-signed
|
|
|
86
154
|
Early proof of concept, macOS (arm64) and Linux (WebKitGTK). The design
|
|
87
155
|
notes and scriptc findings behind it are in
|
|
88
156
|
[docs/findings.md](docs/findings.md). Not yet: Windows, async commands
|
|
89
|
-
|
|
157
|
+
that run in parallel (host code is single-threaded; `commandAsync` interleaves
|
|
158
|
+
instead), native dialogs/tray/menus, multi-window,
|
|
90
159
|
icons/installers/notarization.
|
|
91
160
|
|
|
161
|
+
## Releasing
|
|
162
|
+
|
|
163
|
+
Bump `version` in `package.json` and merge to `main`. The publish workflow
|
|
164
|
+
then does the rest: it compares the version against the registry, and if it
|
|
165
|
+
is new, scaffolds/builds/runs a smoke app on Linux, publishes to npm with
|
|
166
|
+
trusted publishing (OIDC — no token secret), tags the published commit
|
|
167
|
+
`v<version>`, and opens a GitHub release with generated notes.
|
|
168
|
+
|
|
169
|
+
A merge that does not change the version is a clean no-op: nothing is
|
|
170
|
+
published and no tag is created. If a publish ever succeeds but the tagging
|
|
171
|
+
step does not, npm and git are briefly out of step — reconcile with:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
git tag -a v<version> -m "janela <version>" <commit>
|
|
175
|
+
git push origin v<version>
|
|
176
|
+
gh release create v<version> --generate-notes --verify-tag
|
|
177
|
+
```
|
|
178
|
+
|
|
92
179
|
## License
|
|
93
180
|
|
|
94
181
|
MIT. Bundles [webview/webview](https://github.com/webview/webview) headers
|
package/bin/janela.mjs
CHANGED
|
@@ -54,16 +54,95 @@ function loadConf(root) {
|
|
|
54
54
|
return c;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// ---- Windows toolchain ------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
// scriptc's win32 lane is MinGW-shaped twice over: its runtime uses POSIX
|
|
60
|
+
// types the MSVC CRT lacks (ssize_t), and its event loop calls POSIX time
|
|
61
|
+
// APIs that only a full mingw-w64 provides ("the idle sleep is nanosleep
|
|
62
|
+
// (mingw-w64 ships it, over Sleep)" — scr_async.c). So Windows builds need a
|
|
63
|
+
// clang whose DEFAULT target is mingw: llvm-mingw, MSYS2's clang64, or
|
|
64
|
+
// WinLibs. A stock MSVC-targeting clang cannot compile scriptc's runtime, and
|
|
65
|
+
// zig's bundled mingw omits winpthreads, so it cannot either.
|
|
66
|
+
function winCcOrFail() {
|
|
67
|
+
const probe = spawnSync("clang", ["-dumpmachine"], { encoding: "utf8" });
|
|
68
|
+
if (probe.status !== 0) {
|
|
69
|
+
fail(
|
|
70
|
+
"Windows builds need a MinGW-targeting clang on PATH. Install llvm-mingw " +
|
|
71
|
+
"(https://github.com/mstorsjo/llvm-mingw/releases) or MSYS2's clang64 toolchain.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const triple = probe.stdout.trim();
|
|
75
|
+
if (!/mingw|windows-gnu/i.test(triple)) {
|
|
76
|
+
fail(
|
|
77
|
+
`clang on PATH targets '${triple}', but scriptc's Windows runtime only builds with ` +
|
|
78
|
+
"MinGW (it uses ssize_t/nanosleep/clock_gettime, which the MSVC CRT lacks). " +
|
|
79
|
+
"Put a MinGW-targeting clang first on PATH — llvm-mingw " +
|
|
80
|
+
"(https://github.com/mstorsjo/llvm-mingw/releases) or MSYS2's clang64.",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return triple;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- WebView2 SDK (Windows only) -------------------------------------------
|
|
87
|
+
|
|
88
|
+
// webview.h's Win32 backend includes <WebView2.h>, which ships in Microsoft's
|
|
89
|
+
// nuget package rather than the Windows SDK. Fetch it once into the build
|
|
90
|
+
// cache (a .nupkg is a zip) unless the caller points at their own copy.
|
|
91
|
+
const WEBVIEW2_VERSION = "1.0.2903.40";
|
|
92
|
+
|
|
93
|
+
function webview2Include(cacheDir) {
|
|
94
|
+
const override = process.env.JANELA_WEBVIEW2_INCLUDE;
|
|
95
|
+
if (override) {
|
|
96
|
+
if (!existsSync(join(override, "WebView2.h"))) {
|
|
97
|
+
fail(`JANELA_WEBVIEW2_INCLUDE=${override} does not contain WebView2.h`);
|
|
98
|
+
}
|
|
99
|
+
return override;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sdkDir = join(cacheDir, `webview2-${WEBVIEW2_VERSION}`);
|
|
103
|
+
const incDir = join(sdkDir, "build", "native", "include");
|
|
104
|
+
if (existsSync(join(incDir, "WebView2.h"))) return incDir;
|
|
105
|
+
|
|
106
|
+
console.log(`janela: fetching WebView2 SDK ${WEBVIEW2_VERSION}`);
|
|
107
|
+
const zip = join(cacheDir, `webview2-${WEBVIEW2_VERSION}.zip`);
|
|
108
|
+
const url = `https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/${WEBVIEW2_VERSION}`;
|
|
109
|
+
run([
|
|
110
|
+
"powershell", "-NoProfile", "-NonInteractive", "-Command",
|
|
111
|
+
`$ErrorActionPreference='Stop';` +
|
|
112
|
+
`[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12;` +
|
|
113
|
+
`Invoke-WebRequest -Uri '${url}' -OutFile '${zip}';` +
|
|
114
|
+
`Expand-Archive -Path '${zip}' -DestinationPath '${sdkDir}' -Force`,
|
|
115
|
+
]);
|
|
116
|
+
if (!existsSync(join(incDir, "WebView2.h"))) {
|
|
117
|
+
fail(`WebView2 SDK unpacked to ${sdkDir} but no build/native/include/WebView2.h`);
|
|
118
|
+
}
|
|
119
|
+
return incDir;
|
|
120
|
+
}
|
|
121
|
+
|
|
57
122
|
// ---- shim ----------------------------------------------------------------
|
|
58
123
|
|
|
59
124
|
function buildShim(cacheDir) {
|
|
60
125
|
const src = join(KIT, "shim", "wvshim.cc");
|
|
61
|
-
const
|
|
62
|
-
|
|
126
|
+
const win = process.platform === "win32";
|
|
127
|
+
// On Windows the object file is handed to the link directly: `ar` is not
|
|
128
|
+
// part of an MSVC toolchain, and a lone object needs no archive index.
|
|
129
|
+
const obj = join(cacheDir, win ? "wvshim.obj" : "wvshim.o");
|
|
130
|
+
const lib = win ? obj : join(cacheDir, "libwvshim.a");
|
|
63
131
|
if (existsSync(lib) && statSync(lib).mtimeMs > statSync(src).mtimeMs) return lib;
|
|
64
132
|
|
|
65
133
|
console.log("janela: compiling webview shim");
|
|
66
134
|
const inc = `-I${join(KIT, "vendor-webview", "core", "include")}`;
|
|
135
|
+
if (win) {
|
|
136
|
+
console.log(`janela: building for ${winCcOrFail()}`);
|
|
137
|
+
run([
|
|
138
|
+
"clang++", "-c", src, "-o", obj, "-std=c++17", "-O2", inc,
|
|
139
|
+
// WebView2.h from the nuget SDK, plus mingw's missing EventToken.h.
|
|
140
|
+
`-I${webview2Include(cacheDir)}`,
|
|
141
|
+
`-I${join(KIT, "vendor-webview", "compatibility", "mingw", "include")}`,
|
|
142
|
+
"-DWIN32_LEAN_AND_MEAN", "-D_WIN32_WINNT=0x0601",
|
|
143
|
+
]);
|
|
144
|
+
return lib;
|
|
145
|
+
}
|
|
67
146
|
if (process.platform === "darwin") {
|
|
68
147
|
run(["clang++", "-c", src, "-o", obj, "-std=c++17", "-O2", inc]);
|
|
69
148
|
} else {
|
|
@@ -101,8 +180,36 @@ function ffiManifest(shimLib) {
|
|
|
101
180
|
returns: "i32",
|
|
102
181
|
},
|
|
103
182
|
{ name: "wvTerminate", symbol: "wv_terminate", params: ["i32"], returns: "i32" },
|
|
183
|
+
// async: deferred returns + the UI-thread pump behind app.defer/sleep
|
|
184
|
+
{ name: "wvDefer", symbol: "wv_defer", params: ["i32"], returns: "i32" },
|
|
185
|
+
{ name: "wvResolve", symbol: "wv_resolve", params: ["i32", "i32", "i32"], returns: "i32" },
|
|
186
|
+
{ name: "wvTickStart", symbol: "wv_tick_start", params: ["i32", "i32"], returns: "i32" },
|
|
187
|
+
{ name: "wvTickStop", symbol: "wv_tick_stop", params: ["i32"], returns: "i32" },
|
|
104
188
|
];
|
|
105
189
|
|
|
190
|
+
if (process.platform === "win32") {
|
|
191
|
+
// MinGW ignores MSVC's #pragma comment(lib, ...), so the Win32 imports the
|
|
192
|
+
// WebView2 backend needs are named explicitly. scriptc's own win32 lane
|
|
193
|
+
// already adds advapi32/iphlpapi/ws2_32, so those are omitted here.
|
|
194
|
+
// `c++` pulls libc++ for the shim's std::string/exceptions.
|
|
195
|
+
//
|
|
196
|
+
// `pthread` (mingw's libwinpthread) is here to work around an upstream
|
|
197
|
+
// scriptc bug: its runtime calls clock_gettime/nanosleep, which mingw
|
|
198
|
+
// declares in <time.h> but implements in winpthreads, and scriptc's win32
|
|
199
|
+
// link never adds it. Without this the link dies with
|
|
200
|
+
// "undefined symbol: clock_gettime" — reproducible with a plain
|
|
201
|
+
// `scriptc build hello.ts` on Windows, no FFI involved.
|
|
202
|
+
return {
|
|
203
|
+
ffi_format: 2,
|
|
204
|
+
functions,
|
|
205
|
+
libraries: [shimLib],
|
|
206
|
+
system_libraries: [
|
|
207
|
+
"c++", "pthread",
|
|
208
|
+
"ole32", "oleaut32", "shlwapi", "shell32", "user32", "version", "gdi32",
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
106
213
|
if (process.platform === "darwin") {
|
|
107
214
|
// scriptc has no -framework support, but `libraries` entries are passed to
|
|
108
215
|
// the link as plain input files and ld64 accepts .tbd stubs.
|
|
@@ -180,9 +287,17 @@ function build(root) {
|
|
|
180
287
|
writeFileSync(join(buildDir, "janela.ffi.json"), JSON.stringify(ffiManifest(shimLib), null, 2) + "\n");
|
|
181
288
|
|
|
182
289
|
console.log("janela: compiling TypeScript to a native binary");
|
|
183
|
-
|
|
290
|
+
// An explicit --out is used verbatim, so the PE suffix is ours to add.
|
|
291
|
+
const bin = join(outDir, process.platform === "win32" ? `${conf.name}.exe` : conf.name);
|
|
292
|
+
// No SCRIPTC_CC/SCRIPTC_TARGET on Windows: scriptc's default driver is
|
|
293
|
+
// plain `clang`, which is exactly the MinGW-targeting clang checked above.
|
|
184
294
|
run(["node", scriptcBin(), "build", "entry.ts", "--ffi", "janela.ffi.json", "-o", bin], { cwd: buildDir });
|
|
185
295
|
|
|
296
|
+
// Symbol/debug metadata is ~16% of the binary and apps don't need it.
|
|
297
|
+
// (On arm64 macOS, strip re-signs ad-hoc automatically. MinGW keeps DWARF
|
|
298
|
+
// inside the .exe rather than a side-by-side .pdb, so Windows benefits too.)
|
|
299
|
+
run(["strip", bin]);
|
|
300
|
+
|
|
186
301
|
if (process.platform === "darwin") {
|
|
187
302
|
const bundle = join(outDir, `${conf.name}.app`);
|
|
188
303
|
mkdirSync(join(bundle, "Contents", "MacOS"), { recursive: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "janela",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Desktop apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"type": "git",
|
|
17
17
|
"url": "git+https://github.com/mmamedel/janela.git"
|
|
18
18
|
},
|
|
19
|
-
"homepage": "https://github.
|
|
19
|
+
"homepage": "https://mmamedel.github.io/janela/",
|
|
20
20
|
"bugs": {
|
|
21
21
|
"url": "https://github.com/mmamedel/janela/issues"
|
|
22
22
|
},
|
package/runtime/janela.ts
CHANGED
|
@@ -22,6 +22,13 @@ declare function wvReplyReset(h: number): number;
|
|
|
22
22
|
declare function wvReplyPush(h: number, b: number): number;
|
|
23
23
|
declare function wvRun(h: number, cb: (bindIndex: number, seq: number) => number): number;
|
|
24
24
|
declare function wvTerminate(h: number): number;
|
|
25
|
+
declare function wvDefer(h: number): number;
|
|
26
|
+
declare function wvResolve(h: number, id: number, status: number): number;
|
|
27
|
+
declare function wvTickStart(h: number, intervalMs: number): number;
|
|
28
|
+
declare function wvTickStop(h: number): number;
|
|
29
|
+
|
|
30
|
+
// Bind index the shim uses for a timer tick rather than a page invoke.
|
|
31
|
+
const TICK_BIND = 4294967295;
|
|
25
32
|
|
|
26
33
|
// Injected into every page before it loads (webview_init).
|
|
27
34
|
const BOOTSTRAP =
|
|
@@ -45,6 +52,18 @@ const BOOTSTRAP =
|
|
|
45
52
|
// scriptc across the FFI boundary — return an error envelope instead.
|
|
46
53
|
export type CommandHandler = (argsJson: string) => string;
|
|
47
54
|
|
|
55
|
+
/**
|
|
56
|
+
* An async command: return immediately, answer later. `resolve`/`reject` take
|
|
57
|
+
* JSON text and settle the page's `await janela.invoke(...)` promise whenever
|
|
58
|
+
* they are called — from a later defer()/sleep() turn, or from another
|
|
59
|
+
* command. The window stays responsive for as long as the call is pending.
|
|
60
|
+
*/
|
|
61
|
+
export type AsyncCommandHandler = (
|
|
62
|
+
argsJson: string,
|
|
63
|
+
resolve: (json: string) => void,
|
|
64
|
+
reject: (json: string) => void,
|
|
65
|
+
) => void;
|
|
66
|
+
|
|
48
67
|
export interface WindowConfig {
|
|
49
68
|
title: string;
|
|
50
69
|
width: number;
|
|
@@ -57,6 +76,13 @@ export interface JanelaApp {
|
|
|
57
76
|
handlers: CommandHandler[];
|
|
58
77
|
/** Register a named command, callable from the page as janela.invoke(name, args). */
|
|
59
78
|
command: (name: string, h: CommandHandler) => void;
|
|
79
|
+
/** Register a command that answers later; see AsyncCommandHandler. */
|
|
80
|
+
commandAsync: (name: string, h: AsyncCommandHandler) => void;
|
|
81
|
+
/** Run fn on the next turn of the host loop — the way to slice long work. */
|
|
82
|
+
defer: (fn: () => void) => void;
|
|
83
|
+
/** Run fn after at least ms. The host loop's timer; scriptc's setTimeout
|
|
84
|
+
* cannot fire while the window is open (its loop is parked inside run()). */
|
|
85
|
+
sleep: (ms: number, fn: () => void) => void;
|
|
60
86
|
/** Fire an event into the page; payloadJson must be valid JSON text. */
|
|
61
87
|
emit: (event: string, payloadJson: string) => void;
|
|
62
88
|
/** Close the window and make run() return. */
|
|
@@ -144,6 +170,60 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
144
170
|
wvSetSize(h, cfg.width, cfg.height, 0);
|
|
145
171
|
wvInit(h, BOOTSTRAP);
|
|
146
172
|
|
|
173
|
+
// ---- the host loop -------------------------------------------------------
|
|
174
|
+
// scriptc's event loop is parked for as long as the program sits inside the
|
|
175
|
+
// wvRun() FFI call, so setTimeout/await never fire while the window is open.
|
|
176
|
+
// These queues are drained instead by TICK_BIND callbacks that the shim's
|
|
177
|
+
// ticker posts to the UI thread, and the ticker only runs while there is
|
|
178
|
+
// work — an idle app costs nothing.
|
|
179
|
+
const asyncNames: string[] = [];
|
|
180
|
+
const asyncHandlers: AsyncCommandHandler[] = [];
|
|
181
|
+
let taskFns: (() => void)[] = [];
|
|
182
|
+
let timerFns: (() => void)[] = [];
|
|
183
|
+
let timerDue: number[] = [];
|
|
184
|
+
let ticking = false;
|
|
185
|
+
|
|
186
|
+
const wake = (): void => {
|
|
187
|
+
if (ticking) return;
|
|
188
|
+
ticking = true;
|
|
189
|
+
wvTickStart(h, 8);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const idle = (): void => {
|
|
193
|
+
if (!ticking) return;
|
|
194
|
+
if (taskFns.length > 0 || timerFns.length > 0) return;
|
|
195
|
+
ticking = false;
|
|
196
|
+
wvTickStop(h);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// One turn of the loop: every task queued so far, plus every due timer.
|
|
200
|
+
// Tasks queued *by* this turn wait for the next one, so a defer() chain
|
|
201
|
+
// yields to the UI between slices instead of starving it.
|
|
202
|
+
const turn = (): void => {
|
|
203
|
+
const tasks = taskFns;
|
|
204
|
+
taskFns = [];
|
|
205
|
+
for (let i = 0; i < tasks.length; i++) tasks[i]();
|
|
206
|
+
|
|
207
|
+
if (timerFns.length > 0) {
|
|
208
|
+
const now = Date.now() + 0;
|
|
209
|
+
const keptFns: (() => void)[] = [];
|
|
210
|
+
const keptDue: number[] = [];
|
|
211
|
+
const fire: (() => void)[] = [];
|
|
212
|
+
for (let i = 0; i < timerFns.length; i++) {
|
|
213
|
+
if (timerDue[i] <= now) {
|
|
214
|
+
fire.push(timerFns[i]);
|
|
215
|
+
} else {
|
|
216
|
+
keptFns.push(timerFns[i]);
|
|
217
|
+
keptDue.push(timerDue[i]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
timerFns = keptFns;
|
|
221
|
+
timerDue = keptDue;
|
|
222
|
+
for (let i = 0; i < fire.length; i++) fire[i]();
|
|
223
|
+
}
|
|
224
|
+
idle();
|
|
225
|
+
};
|
|
226
|
+
|
|
147
227
|
const app: JanelaApp = {
|
|
148
228
|
handle: h,
|
|
149
229
|
names: [],
|
|
@@ -154,6 +234,22 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
154
234
|
app.handlers.push(handler);
|
|
155
235
|
},
|
|
156
236
|
|
|
237
|
+
commandAsync: (name, handler) => {
|
|
238
|
+
asyncNames.push(name);
|
|
239
|
+
asyncHandlers.push(handler);
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
defer: (fn) => {
|
|
243
|
+
taskFns.push(fn);
|
|
244
|
+
wake();
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
sleep: (ms, fn) => {
|
|
248
|
+
timerFns.push(fn);
|
|
249
|
+
timerDue.push(Date.now() + (ms > 0 ? ms : 0));
|
|
250
|
+
wake();
|
|
251
|
+
},
|
|
252
|
+
|
|
157
253
|
emit: (event, payloadJson) => {
|
|
158
254
|
wvEval(
|
|
159
255
|
h,
|
|
@@ -170,6 +266,12 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
170
266
|
wvSetHtml(h, html);
|
|
171
267
|
|
|
172
268
|
const rc = wvRun(h, (bindIndex, _seq) => {
|
|
269
|
+
// A tick is not an invoke: nothing is waiting on a reply, so the
|
|
270
|
+
// shim never calls webview_return for it.
|
|
271
|
+
if (bindIndex === TICK_BIND) {
|
|
272
|
+
turn();
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
173
275
|
if (bindIndex !== INVOKE) {
|
|
174
276
|
writeReply(h, '"unknown binding"');
|
|
175
277
|
return 1;
|
|
@@ -183,6 +285,29 @@ export function createApp(cfg: WindowConfig): JanelaApp {
|
|
|
183
285
|
return 0;
|
|
184
286
|
}
|
|
185
287
|
}
|
|
288
|
+
for (let i = 0; i < asyncNames.length; i++) {
|
|
289
|
+
if (asyncNames[i] === cmd) {
|
|
290
|
+
// Park the page's promise: the shim holds this call's id and
|
|
291
|
+
// answers it when resolve/reject reaches wvResolve, whenever
|
|
292
|
+
// that is. Meanwhile the loop is free to serve other calls.
|
|
293
|
+
const id = wvDefer(h) + 0;
|
|
294
|
+
if (id < 0) {
|
|
295
|
+
writeReply(h, JSON.stringify("cannot defer command: " + cmd));
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
const settle = (status: number): ((json: string) => void) => {
|
|
299
|
+
let done = false;
|
|
300
|
+
return (json: string) => {
|
|
301
|
+
if (done) return; // a promise settles once
|
|
302
|
+
done = true;
|
|
303
|
+
writeReply(h, json);
|
|
304
|
+
wvResolve(h, id, status);
|
|
305
|
+
};
|
|
306
|
+
};
|
|
307
|
+
asyncHandlers[i](argsJson, settle(0), settle(1));
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
186
311
|
writeReply(h, JSON.stringify("unknown command: " + cmd));
|
|
187
312
|
return 1; // rejects the frontend promise
|
|
188
313
|
}) + 0;
|
package/shim/wvshim.cc
CHANGED
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
|
|
14
14
|
#include "webview.h"
|
|
15
15
|
|
|
16
|
+
#include <atomic>
|
|
17
|
+
#include <chrono>
|
|
16
18
|
#include <cstdint>
|
|
17
19
|
#include <cstring>
|
|
18
20
|
#include <string>
|
|
21
|
+
#include <thread>
|
|
19
22
|
#include <vector>
|
|
20
23
|
|
|
21
24
|
namespace {
|
|
@@ -24,6 +27,13 @@ struct Bind {
|
|
|
24
27
|
std::string name;
|
|
25
28
|
};
|
|
26
29
|
|
|
30
|
+
// An invoke whose webview_return was postponed by wv_defer(): the page's
|
|
31
|
+
// promise stays unsettled until TS calls wv_resolve() on a later turn.
|
|
32
|
+
struct Pending {
|
|
33
|
+
bool used = false;
|
|
34
|
+
std::string call_id;
|
|
35
|
+
};
|
|
36
|
+
|
|
27
37
|
struct App {
|
|
28
38
|
webview_t w = nullptr;
|
|
29
39
|
bool used = false;
|
|
@@ -38,8 +48,19 @@ struct App {
|
|
|
38
48
|
std::string cur_id; // webview's call id, needed by webview_return
|
|
39
49
|
std::string reply; // response body assembled by TS
|
|
40
50
|
uint32_t seq = 0;
|
|
51
|
+
|
|
52
|
+
// ---- async support ----
|
|
53
|
+
std::vector<Pending> pending; // deferred invokes, addressed by index
|
|
54
|
+
bool deferred = false; // set by wv_defer() during the current call
|
|
55
|
+
std::thread ticker; // pure-C++ thread; never touches TS itself
|
|
56
|
+
std::atomic<bool> ticking{false};
|
|
57
|
+
std::atomic<int32_t> tick_ms{16};
|
|
41
58
|
};
|
|
42
59
|
|
|
60
|
+
// Bind index handed to the TS callback for a timer tick rather than an
|
|
61
|
+
// invoke. Real bind indices are small sequential integers.
|
|
62
|
+
const uint32_t TICK_BIND = 0xffffffffu;
|
|
63
|
+
|
|
43
64
|
// Fixed-size table: handles are indices, never pointers.
|
|
44
65
|
App g_apps[8];
|
|
45
66
|
|
|
@@ -65,14 +86,37 @@ void trampoline(const char *id, const char *req, void *arg) {
|
|
|
65
86
|
a->req = req ? req : "";
|
|
66
87
|
a->cur_id = id ? id : "";
|
|
67
88
|
a->reply.clear();
|
|
89
|
+
a->deferred = false;
|
|
68
90
|
a->seq++;
|
|
69
91
|
|
|
70
92
|
// Re-entrancy: this call lands back in TS, which will call wv_req_byte()
|
|
71
93
|
// etc. back into this shim before returning.
|
|
72
94
|
int32_t status = a->cb(bind_index, a->seq, a->cb_ctx);
|
|
73
95
|
|
|
96
|
+
// An async handler called wv_defer(): the call id now lives in the pending
|
|
97
|
+
// table and wv_resolve() will answer the page later. Returning now would
|
|
98
|
+
// settle the promise with a stale value.
|
|
99
|
+
if (a->deferred) {
|
|
100
|
+
a->deferred = false;
|
|
101
|
+
a->cur_id.clear();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
74
105
|
webview_return(a->w, a->cur_id.c_str(), status,
|
|
75
106
|
a->reply.empty() ? "null" : a->reply.c_str());
|
|
107
|
+
// wv_defer() treats a non-empty cur_id as "an invoke is in flight". Clearing
|
|
108
|
+
// it here means a defer from anywhere else — a tick, say — fails with -1
|
|
109
|
+
// instead of stealing this already-answered call's id.
|
|
110
|
+
a->cur_id.clear();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Runs on the UI thread (posted by the ticker via webview_dispatch), so the
|
|
114
|
+
// TS it calls stays single-threaded — scriptc's runtime is NOT thread-safe.
|
|
115
|
+
void tick_on_ui_thread(webview_t, void *arg) {
|
|
116
|
+
App *a = &g_apps[reinterpret_cast<uintptr_t>(arg)];
|
|
117
|
+
if (!a->used || !a->cb) return; // app quit between dispatch and delivery
|
|
118
|
+
a->seq++;
|
|
119
|
+
a->cb(TICK_BIND, a->seq, a->cb_ctx);
|
|
76
120
|
}
|
|
77
121
|
|
|
78
122
|
} // namespace
|
|
@@ -84,7 +128,19 @@ int32_t wv_create(int32_t debug) {
|
|
|
84
128
|
if (g_apps[i].used) continue;
|
|
85
129
|
webview_t w = webview_create(debug, nullptr);
|
|
86
130
|
if (!w) return -1;
|
|
87
|
-
|
|
131
|
+
// Field-wise reset: App holds a thread and atomics, so it is not
|
|
132
|
+
// copy-assignable from a temporary.
|
|
133
|
+
g_apps[i].binds.clear();
|
|
134
|
+
g_apps[i].cb = nullptr;
|
|
135
|
+
g_apps[i].cb_ctx = nullptr;
|
|
136
|
+
g_apps[i].req.clear();
|
|
137
|
+
g_apps[i].cur_id.clear();
|
|
138
|
+
g_apps[i].reply.clear();
|
|
139
|
+
g_apps[i].seq = 0;
|
|
140
|
+
g_apps[i].pending.clear();
|
|
141
|
+
g_apps[i].deferred = false;
|
|
142
|
+
g_apps[i].ticking.store(false);
|
|
143
|
+
g_apps[i].tick_ms.store(16);
|
|
88
144
|
g_apps[i].w = w;
|
|
89
145
|
g_apps[i].used = true;
|
|
90
146
|
return i;
|
|
@@ -170,6 +226,75 @@ int32_t wv_reply_push(int32_t h, int32_t byte) {
|
|
|
170
226
|
return 0;
|
|
171
227
|
}
|
|
172
228
|
|
|
229
|
+
// ---- async: deferred returns + a UI-thread pump -----------------------------
|
|
230
|
+
//
|
|
231
|
+
// scriptc's own event loop does not run while the program sits inside an FFI
|
|
232
|
+
// call, and wv_run() is one such call for the app's whole life — so setTimeout
|
|
233
|
+
// and promise continuations in TS never fire while the window is open. These
|
|
234
|
+
// four functions supply the missing loop: TS may postpone an invoke's answer
|
|
235
|
+
// (wv_defer), answer it later (wv_resolve), and get called back periodically
|
|
236
|
+
// on the UI thread to make progress (wv_tick_start / wv_tick_stop).
|
|
237
|
+
|
|
238
|
+
// Postpone the answer to the invoke being handled right now. Returns a
|
|
239
|
+
// pending id to hand back to wv_resolve(), or -1 outside a bind callback.
|
|
240
|
+
int32_t wv_defer(int32_t h) {
|
|
241
|
+
App *a = app_at(h);
|
|
242
|
+
if (!a || a->cur_id.empty()) return -1;
|
|
243
|
+
for (size_t i = 0; i < a->pending.size(); i++) {
|
|
244
|
+
if (!a->pending[i].used) {
|
|
245
|
+
a->pending[i].used = true;
|
|
246
|
+
a->pending[i].call_id = a->cur_id;
|
|
247
|
+
a->deferred = true;
|
|
248
|
+
return static_cast<int32_t>(i);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
a->pending.push_back(Pending{true, a->cur_id});
|
|
252
|
+
a->deferred = true;
|
|
253
|
+
return static_cast<int32_t>(a->pending.size() - 1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Answer a deferred invoke with whatever TS has staged via wv_reply_push().
|
|
257
|
+
// status 0 resolves the page's promise, non-zero rejects it.
|
|
258
|
+
int32_t wv_resolve(int32_t h, int32_t id, int32_t status) {
|
|
259
|
+
App *a = app_at(h);
|
|
260
|
+
if (!a || id < 0 || static_cast<size_t>(id) >= a->pending.size()) return -1;
|
|
261
|
+
Pending &p = a->pending[id];
|
|
262
|
+
if (!p.used) return -1;
|
|
263
|
+
webview_return(a->w, p.call_id.c_str(), status,
|
|
264
|
+
a->reply.empty() ? "null" : a->reply.c_str());
|
|
265
|
+
p.used = false;
|
|
266
|
+
p.call_id.clear();
|
|
267
|
+
a->reply.clear();
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Start pumping TS with TICK_BIND callbacks every interval_ms. The thread
|
|
272
|
+
// itself only sleeps and posts; all TS execution happens on the UI thread.
|
|
273
|
+
int32_t wv_tick_start(int32_t h, int32_t interval_ms) {
|
|
274
|
+
App *a = app_at(h);
|
|
275
|
+
if (!a) return -1;
|
|
276
|
+
a->tick_ms = interval_ms > 0 ? interval_ms : 16;
|
|
277
|
+
if (a->ticking.exchange(true)) return 0; // already running
|
|
278
|
+
uintptr_t idx = static_cast<uintptr_t>(h);
|
|
279
|
+
a->ticker = std::thread([a, idx]() {
|
|
280
|
+
while (a->ticking.load()) {
|
|
281
|
+
std::this_thread::sleep_for(
|
|
282
|
+
std::chrono::milliseconds(a->tick_ms.load()));
|
|
283
|
+
if (!a->ticking.load()) break;
|
|
284
|
+
webview_dispatch(a->w, tick_on_ui_thread, reinterpret_cast<void *>(idx));
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
int32_t wv_tick_stop(int32_t h) {
|
|
291
|
+
App *a = app_at(h);
|
|
292
|
+
if (!a) return -1;
|
|
293
|
+
if (!a->ticking.exchange(false)) return 0;
|
|
294
|
+
if (a->ticker.joinable()) a->ticker.join();
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
173
298
|
// Blocks for the app's lifetime; dispatches bind calls into `cb`.
|
|
174
299
|
int32_t wv_run(int32_t h, int32_t (*cb)(uint32_t, uint32_t, void *),
|
|
175
300
|
void *cb_ctx) {
|
|
@@ -178,6 +303,9 @@ int32_t wv_run(int32_t h, int32_t (*cb)(uint32_t, uint32_t, void *),
|
|
|
178
303
|
a->cb = cb;
|
|
179
304
|
a->cb_ctx = cb_ctx;
|
|
180
305
|
int rc = webview_run(a->w);
|
|
306
|
+
// The ticker must not outlive the callback it drives.
|
|
307
|
+
a->ticking.store(false);
|
|
308
|
+
if (a->ticker.joinable()) a->ticker.join();
|
|
181
309
|
a->cb = nullptr; // callback must not outlive the call, per lifetime:"call"
|
|
182
310
|
a->cb_ctx = nullptr;
|
|
183
311
|
return rc;
|
|
@@ -192,6 +320,8 @@ int32_t wv_terminate(int32_t h) {
|
|
|
192
320
|
int32_t wv_destroy(int32_t h) {
|
|
193
321
|
App *a = app_at(h);
|
|
194
322
|
if (!a) return -1;
|
|
323
|
+
a->ticking.store(false);
|
|
324
|
+
if (a->ticker.joinable()) a->ticker.join();
|
|
195
325
|
webview_destroy(a->w);
|
|
196
326
|
a->used = false;
|
|
197
327
|
a->w = nullptr;
|
package/templates/index.html
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<input id="a" type="number" value="2" /> +
|
|
15
15
|
<input id="b" type="number" value="40" />
|
|
16
16
|
<button id="add">add</button>
|
|
17
|
+
<button id="wait">wait 2s (async)</button>
|
|
17
18
|
<button id="quit">Quit</button>
|
|
18
19
|
</p>
|
|
19
20
|
<pre id="out">booting…</pre>
|
|
@@ -41,6 +42,15 @@
|
|
|
41
42
|
const sum = await janela.invoke("add", { a, b });
|
|
42
43
|
out.textContent = `add(${a}, ${b}) -> ${sum}`;
|
|
43
44
|
};
|
|
45
|
+
// The window stays responsive while this is pending: the spinner keeps
|
|
46
|
+
// animating and "add" still works.
|
|
47
|
+
document.getElementById("wait").onclick = async (e) => {
|
|
48
|
+
e.target.disabled = true;
|
|
49
|
+
out.textContent = "waiting… (try 'add' — it still answers)";
|
|
50
|
+
out.textContent = await janela.invoke("wait", { ms: 2000 });
|
|
51
|
+
e.target.disabled = false;
|
|
52
|
+
};
|
|
53
|
+
|
|
44
54
|
document.getElementById("quit").onclick = () => janela.invoke("quit");
|
|
45
55
|
};
|
|
46
56
|
</script>
|
package/templates/main.ts
CHANGED
|
@@ -30,6 +30,15 @@ export function setup(app: JanelaApp): void {
|
|
|
30
30
|
return "null";
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
// An async command: answers later, without freezing the window. The page
|
|
34
|
+
// still just does `await janela.invoke("wait", { ms: 1000 })`.
|
|
35
|
+
app.commandAsync("wait", (argsJson, resolve) => {
|
|
36
|
+
const a = JSON.parse(argsJson) as { ms: number };
|
|
37
|
+
app.sleep(a.ms, () => {
|
|
38
|
+
resolve(JSON.stringify("waited " + a.ms + "ms without blocking the UI"));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
33
42
|
app.command("quit", (_argsJson) => {
|
|
34
43
|
app.quit();
|
|
35
44
|
return "null";
|