Skip to content

Commit 7e62b2c

Browse files
committed
Harden WebUI API lifecycle contracts
1 parent cff04cb commit 7e62b2c

10 files changed

Lines changed: 1751 additions & 71 deletions

README.md

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,34 @@ flowchart LR
9494
F --> G["WebView window (OS)"]
9595
```
9696

97+
## Launch Policy (Deterministic)
98+
99+
`AppOptions` now uses a single deterministic policy object:
100+
101+
```zig
102+
pub const LaunchPolicy = struct {
103+
preferred_transport: enum { native_webview, browser } = .native_webview,
104+
fallback_transport: enum { none, browser } = .browser,
105+
browser_open_mode: enum { never, on_browser_transport, always } = .on_browser_transport,
106+
allow_dual_surface: bool = false,
107+
app_mode_required: bool = false,
108+
};
109+
```
110+
111+
Decision summary:
112+
113+
| `preferred_transport` | Native backend available | `fallback_transport` | Active transport |
114+
|---|---|---|---|
115+
| `native_webview` | yes | any | `native_webview` |
116+
| `native_webview` | no | `browser` | `browser_fallback` |
117+
| `native_webview` | no | `none` | `native_webview` (error if render required) |
118+
| `browser` | any | any | `browser_fallback` |
119+
120+
Browser opening summary:
121+
- `never`: never auto-launch browser.
122+
- `on_browser_transport`: launch browser only when active transport resolves to browser.
123+
- `always`: always launch browser; combine with `allow_dual_surface=true` for explicit dual-surface behavior.
124+
97125
## API At A Glance
98126

99127
Core flow:
@@ -118,9 +146,11 @@ pub fn main() !void {
118146
119147
var service = try webui.Service.init(gpa.allocator(), rpc_methods, .{
120148
.app = .{
121-
.transport_mode = .native_webview,
122-
.browser_fallback_on_native_failure = true,
123-
.auto_open_browser = true,
149+
.launch_policy = .{
150+
.preferred_transport = .native_webview,
151+
.fallback_transport = .browser,
152+
.browser_open_mode = .on_browser_transport,
153+
},
124154
},
125155
.window = .{ .title = "WebUI Zig" },
126156
.rpc = .{ .dispatcher_mode = .threaded },
@@ -167,6 +197,24 @@ Dispatch modes:
167197
- `threaded` (worker queue)
168198
- `custom` (hook dispatcher)
169199

200+
Async jobs (push-first):
201+
- Set `RpcOptions.execution_mode = .queued_async`.
202+
- `POST <rpc_route>` returns `{ job_id, state, poll_min_ms, poll_max_ms }`.
203+
- Completion is pushed over WebSocket as `rpc_job_update`.
204+
- JS bridge waits for push first, then uses bounded polling fallback (`GET /rpc/job?id=...`).
205+
- Cancel route: `POST /rpc/job/cancel` with `{ "job_id": <id> }`.
206+
207+
Typed APIs:
208+
- `Window.rpcPollJob(allocator, job_id) !RpcJobStatus`
209+
- `Window.rpcCancelJob(job_id) !bool`
210+
- `Service` mirrors both methods.
211+
212+
Runtime introspection and diagnostics:
213+
- `Window.runtimeRenderState()` / `Service.runtimeRenderState()`
214+
- `Window.probeCapabilities()` / `Service.probeCapabilities()`
215+
- `Service.listRuntimeRequirements(allocator)`
216+
- `App.onDiagnostic(...)` / `Service.onDiagnostic(...)`
217+
170218
## Close Semantics (No Random Window Closes)
171219

172220
Close is backend-authoritative:
@@ -197,6 +245,24 @@ Build outputs:
197245
- `zig-out/share/webui/runtime_helpers.embed.js`
198246
- `zig-out/share/webui/runtime_helpers.written.js`
199247

248+
## Linux Runtime Requirements API
249+
250+
You can query runtime packaging requirements before showing a window:
251+
252+
```zig
253+
const reqs = try service.listRuntimeRequirements(allocator);
254+
defer allocator.free(reqs);
255+
for (reqs) |req| {
256+
std.debug.print("{s}: required={any} available={any}\n", .{
257+
req.name, req.required, req.available,
258+
});
259+
}
260+
```
261+
262+
On Linux this reports helper/runtime expectations such as:
263+
- `webui_linux_webview_host`
264+
- `webui_linux_browser_host`
265+
200266
## Build Flags
201267

202268
| Flag | Default | What it does |
@@ -220,6 +286,7 @@ Exported compile-time values:
220286

221287
- `zig build` (install)
222288
- `zig build test`
289+
- `zig build dispatcher-stress`
223290
- `zig build examples`
224291
- `zig build run`
225292
- `zig build bridge`
@@ -289,6 +356,6 @@ Manual GUI validation checklist: `docs/manual_gui_checklist.md`.
289356

290357
## Docs
291358

292-
- `MIGRATION.md`
359+
- `docs/migration.md`
293360
- `CHANGELOG.md`
294361
- `docs/upstream_file_parity.md`

build.zig

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,20 @@ pub fn build(b: *Build) void {
360360
test_step.dependOn(step);
361361
}
362362

363+
const dispatcher_stress_tests = b.addTest(.{
364+
.root_module = webui_mod,
365+
.filters = &.{"threaded dispatcher stress"},
366+
});
367+
dispatcher_stress_tests.step.dependOn(runtime_helpers_assets.prepare_step);
368+
dispatcher_stress_tests.linkLibrary(webui_lib);
369+
370+
const dispatcher_stress_step = b.step("dispatcher-stress", "Stress threaded dispatcher concurrency/lifetime paths");
371+
var stress_iter: usize = 0;
372+
while (stress_iter < 8) : (stress_iter += 1) {
373+
const run_stress = b.addRunArtifact(dispatcher_stress_tests);
374+
dispatcher_stress_step.dependOn(&run_stress.step);
375+
}
376+
363377
const parity_report_tool = b.addExecutable(.{
364378
.name = "parity_report",
365379
.root_module = b.createModule(.{

docs/manual_gui_checklist.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ For each OS, validate at least:
2222
- Additional installed third-party browsers where available (Arc, Sidekick, Shift, DuckDuckGo, SigmaOS, Lightpanda)
2323

2424
For each browser:
25-
- Launch example app and confirm browser opens automatically when `auto_open_browser=true`.
25+
- Launch example app and confirm browser opens when `launch_policy.browser_open_mode` allows browser transport.
2626
- Confirm app opens with explicit env override:
2727
- `WEBUI_BROWSER_PATH`
2828
- `WEBUI_BROWSER`

docs/migration.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Migration Guide: Launch Policy + Async RPC Jobs
2+
3+
This guide covers the hard API replacement in `AppOptions` and the new async RPC job APIs.
4+
5+
## AppOptions Launch Fields (Hard Replace)
6+
7+
Removed fields:
8+
- `transport_mode`
9+
- `auto_open_browser`
10+
- `browser_fallback_on_native_failure`
11+
12+
Replacement:
13+
- `launch_policy: LaunchPolicy`
14+
15+
```zig
16+
pub const LaunchPolicy = struct {
17+
preferred_transport: enum { native_webview, browser } = .native_webview,
18+
fallback_transport: enum { none, browser } = .browser,
19+
browser_open_mode: enum { never, on_browser_transport, always } = .on_browser_transport,
20+
allow_dual_surface: bool = false,
21+
app_mode_required: bool = false,
22+
};
23+
```
24+
25+
## Old -> New Mapping
26+
27+
### Native-first with browser fallback
28+
29+
Before:
30+
31+
```zig
32+
.app = .{
33+
.transport_mode = .native_webview,
34+
.browser_fallback_on_native_failure = true,
35+
.auto_open_browser = true,
36+
}
37+
```
38+
39+
After:
40+
41+
```zig
42+
.app = .{
43+
.launch_policy = .{
44+
.preferred_transport = .native_webview,
45+
.fallback_transport = .browser,
46+
.browser_open_mode = .on_browser_transport,
47+
},
48+
}
49+
```
50+
51+
### Browser-only mode
52+
53+
Before:
54+
55+
```zig
56+
.app = .{
57+
.transport_mode = .browser_fallback,
58+
.auto_open_browser = true,
59+
}
60+
```
61+
62+
After:
63+
64+
```zig
65+
.app = .{
66+
.launch_policy = .{
67+
.preferred_transport = .browser,
68+
.fallback_transport = .browser,
69+
.browser_open_mode = .on_browser_transport,
70+
},
71+
}
72+
```
73+
74+
### Native-only required mode (no browser fallback)
75+
76+
```zig
77+
.app = .{
78+
.launch_policy = .{
79+
.preferred_transport = .native_webview,
80+
.fallback_transport = .none,
81+
.browser_open_mode = .never,
82+
.app_mode_required = true,
83+
},
84+
}
85+
```
86+
87+
## New Introspection APIs
88+
89+
Use these to avoid warning-string parsing:
90+
- `Window.runtimeRenderState()` / `Service.runtimeRenderState()`
91+
- `Window.probeCapabilities()` / `Service.probeCapabilities()`
92+
- `Service.listRuntimeRequirements(allocator)`
93+
- `App.onDiagnostic(...)` / `Service.onDiagnostic(...)`
94+
95+
## Async RPC Jobs (Push-First + Poll Fallback)
96+
97+
Enable async jobs:
98+
99+
```zig
100+
try window.bindRpc(rpc_methods, .{
101+
.execution_mode = .queued_async,
102+
.job_queue_capacity = 1024,
103+
.job_poll_min_ms = 200,
104+
.job_poll_max_ms = 1000,
105+
.push_job_updates = true,
106+
});
107+
```
108+
109+
Runtime behavior:
110+
- `POST <rpc_route>` returns `job_id` immediately.
111+
- Completion updates are pushed over `/webui/ws` as `rpc_job_update`.
112+
- If push is unavailable, bridge falls back to bounded polling:
113+
- `GET /rpc/job?id=<job_id>`
114+
- `POST /rpc/job/cancel`
115+
116+
Typed control APIs:
117+
- `Window.rpcPollJob(allocator, job_id) !RpcJobStatus`
118+
- `Window.rpcCancelJob(job_id) !bool`
119+
- `Service.rpcPollJob(...)` / `Service.rpcCancelJob(...)`
120+
121+
## Notes
122+
123+
- Existing warning log strings may still appear, but typed diagnostics are the authoritative integration surface.
124+
- For Linux packaging, validate helper/runtime presence through `listRuntimeRequirements`.

src/bridge/generated/runtime_helpers.embed.js

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@ async function __webuiInvoke(endpoint, name, args) {
2424
const text = await res.text();
2525
throw new Error(`RPC ${name} failed: ${res.status} ${text}`);
2626
}
27-
return await res.json();
27+
const body = await res.json();
28+
if (body && typeof body === "object" && Number.isFinite(Number(body.job_id))) {
29+
const jobId = Math.trunc(Number(body.job_id));
30+
const pollMin = Number.isFinite(Number(body.poll_min_ms)) ? Math.max(50, Math.trunc(Number(body.poll_min_ms))) : 200;
31+
const pollMax = Number.isFinite(Number(body.poll_max_ms)) ? Math.max(pollMin, Math.trunc(Number(body.poll_max_ms))) : 1000;
32+
return await __webuiAwaitRpcJob(endpoint, jobId, pollMin, pollMax);
33+
}
34+
return body;
2835
}
2936

3037
async function __webuiJson(endpoint, options) {
@@ -57,6 +64,88 @@ function __webuiNormalizeResult(result) {
5764
return result;
5865
}
5966

67+
const __webuiRpcJobWaiters = new Map();
68+
69+
async function __webuiFetchRpcJobStatus(endpoint, jobId) {
70+
const url = new URL("/rpc/job", globalThis.location ? globalThis.location.href : endpoint);
71+
url.searchParams.set("id", String(jobId));
72+
return await __webuiJson(url.toString(), { method: "GET" });
73+
}
74+
75+
async function __webuiAwaitRpcJob(endpoint, jobId, pollMinMs, pollMaxMs) {
76+
let stopped = false;
77+
let timer = null;
78+
let currentDelay = pollMinMs;
79+
80+
return await new Promise((resolve, reject) => {
81+
const cleanup = () => {
82+
stopped = true;
83+
if (timer) clearTimeout(timer);
84+
timer = null;
85+
__webuiRpcJobWaiters.delete(jobId);
86+
};
87+
88+
const failWithState = (status, fallbackMessage) => {
89+
const message = status && typeof status.error_message === "string" && status.error_message.length > 0
90+
? status.error_message
91+
: fallbackMessage;
92+
reject(new Error(message));
93+
};
94+
95+
const schedulePoll = () => {
96+
if (stopped) return;
97+
timer = setTimeout(() => {
98+
void poll();
99+
}, currentDelay);
100+
currentDelay = Math.min(pollMaxMs, Math.max(pollMinMs, Math.floor(currentDelay * 1.6)));
101+
};
102+
103+
const poll = async () => {
104+
if (stopped) return;
105+
try {
106+
const status = await __webuiFetchRpcJobStatus(endpoint, jobId);
107+
const state = status && typeof status.state === "string" ? status.state : "queued";
108+
if (state === "completed") {
109+
cleanup();
110+
resolve({ value: "value" in status ? status.value : null });
111+
return;
112+
}
113+
if (state === "failed") {
114+
cleanup();
115+
failWithState(status, `RPC job ${jobId} failed`);
116+
return;
117+
}
118+
if (state === "canceled") {
119+
cleanup();
120+
failWithState(status, `RPC job ${jobId} canceled`);
121+
return;
122+
}
123+
if (state === "timed_out") {
124+
cleanup();
125+
failWithState(status, `RPC job ${jobId} timed out`);
126+
return;
127+
}
128+
} catch (_) {
129+
// Keep bounded fallback polling on transient transport errors.
130+
}
131+
schedulePoll();
132+
};
133+
134+
__webuiRpcJobWaiters.set(jobId, {
135+
trigger() {
136+
if (stopped) return;
137+
currentDelay = pollMinMs;
138+
if (timer) clearTimeout(timer);
139+
timer = null;
140+
void poll();
141+
},
142+
});
143+
144+
// Push-first: await server push, but always keep a bounded polling fallback.
145+
schedulePoll();
146+
});
147+
}
148+
60149
let __webuiWindowRuntimeEmulationEnabled = true;
61150
let __webuiWindowRuntimeLoaded = false;
62151

@@ -310,6 +399,14 @@ function __webuiHandleSocketMessage(raw) {
310399
__webuiHandleBackendClose(message);
311400
return;
312401
}
402+
if (message.type === "rpc_job_update") {
403+
const id = Number(message.job_id);
404+
if (Number.isFinite(id)) {
405+
const waiter = __webuiRpcJobWaiters.get(Math.trunc(id));
406+
if (waiter && typeof waiter.trigger === "function") waiter.trigger();
407+
}
408+
return;
409+
}
313410
if (message.type !== "script_task") return;
314411
if (typeof message.script !== "string") return;
315412
void __webuiExecuteScriptTask(message);

0 commit comments

Comments
 (0)