From 23d38993b768c21f29c9a9005a55b59ac38c7381 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 19:56:18 +0800 Subject: [PATCH 01/36] build: add a Zig test name filter option --- build.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build.zig b/build.zig index 4a0b87e..e681f93 100644 --- a/build.zig +++ b/build.zig @@ -15,7 +15,12 @@ pub fn build(b: *std.Build) void { .imports = &.{.{ .name = "Linsang", .module = linsang }}, }); - const tests = b.addTest(.{ .root_module = webui }); + const test_filters = b.option( + []const []const u8, + "test-filter", + "Run only Zig tests whose names contain this text", + ) orelse &.{}; + const tests = b.addTest(.{ .root_module = webui, .filters = test_filters }); const test_step = b.step("test", "Run unit and integration tests"); test_step.dependOn(&b.addRunArtifact(tests).step); From 2ee60e5daeffef623781ee2dbc960452244ecb15 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 19:56:18 +0800 Subject: [PATCH 02/36] test(app): run socket integration scenarios on macOS --- src/app.zig | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app.zig b/src/app.zig index 9927534..0821b4b 100644 --- a/src/app.zig +++ b/src/app.zig @@ -3559,6 +3559,12 @@ fn failingEventHandler(_: *const Event, _: ?*anyopaque) !void { fn noopCallHandler(_: *Call, _: ?*anyopaque) !void {} +/// These long multi-connection scenarios run on Linux and macOS. Windows CI has +/// not validated their shutdown timing, so only that platform skips them. +fn requireSocketIntegration() !void { + if (@import("builtin").os.tag == .windows) return error.SkipZigTest; +} + test "application logger receives level, message, and user data" { const gpa = std.testing.allocator; var capture: LoggerCapture = .{}; @@ -5611,7 +5617,7 @@ fn requireTestRuntime( } test "directory monitor reloads changed window only" { - if (@import("builtin").os.tag != .linux) return error.SkipZigTest; + try requireSocketIntegration(); const gpa = std.testing.allocator; var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); defer threaded.deinit(); @@ -5768,7 +5774,7 @@ test "directory monitor reloads changed window only" { } test "window connection waiting observes clients and timeouts" { - if (@import("builtin").os.tag != .linux) return error.SkipZigTest; + try requireSocketIntegration(); const gpa = std.testing.allocator; var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); defer threaded.deinit(); @@ -5830,7 +5836,7 @@ test "window connection waiting observes clients and timeouts" { } test "binding replies can be deferred, bounded, and disconnected" { - if (@import("builtin").os.tag != .linux) return error.SkipZigTest; + try requireSocketIntegration(); const gpa = std.testing.allocator; var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); defer threaded.deinit(); @@ -5931,7 +5937,7 @@ test "binding replies can be deferred, bounded, and disconnected" { } test "cookie authorization guards WebSocket upgrades" { - if (@import("builtin").os.tag != .linux) return error.SkipZigTest; + try requireSocketIntegration(); const gpa = std.testing.allocator; var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); defer threaded.deinit(); @@ -6031,7 +6037,7 @@ test "cookie authorization guards WebSocket upgrades" { } test "JavaScript and Zig calls complete over HTTP and WebSocket" { - if (@import("builtin").os.tag != .linux) return error.SkipZigTest; + try requireSocketIntegration(); const gpa = std.testing.allocator; var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); defer threaded.deinit(); @@ -6719,7 +6725,7 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" { } test "multi-client limits, targeting, and disconnect lifecycle" { - if (@import("builtin").os.tag != .linux) return error.SkipZigTest; + try requireSocketIntegration(); const gpa = std.testing.allocator; var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); defer threaded.deinit(); From efbdc1d598b6b72050573fbec5a4fdcfe167b90b Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 20:17:01 +0800 Subject: [PATCH 03/36] test(app): wait for client registration before editing eval IDs --- src/app.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app.zig b/src/app.zig index 0821b4b..93fe554 100644 --- a/src/app.zig +++ b/src/app.zig @@ -7714,6 +7714,8 @@ test "exhausted evaluation wire IDs recover after a discarded late reply" { defer stream.close(io); var wire: [125]u8 = undefined; try std.testing.expect(try authenticateTestClient(stream, io, gpa, window.state.token, &window.state.capability, &wire)); + // The acknowledgement precedes client registration to keep wire order. + _ = try window.waitForConnection(io, .fromSeconds(1)); window.state.mutex.lockUncancelable(io); window.state.clients.items[0].retired_eval_ids = std.DynamicBitSetUnmanaged.initFull(gpa, 1 << 16) catch |err| { window.state.mutex.unlock(io); From 4dc207dbe6fbd8ea967c84397aa0ec71cb65c464 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 20:17:01 +0800 Subject: [PATCH 04/36] fix(app): track wait close intent and startup timeout per window Running.wait() now evaluates every window on its own. A backend close ends only that window, other disconnects get the reconnect grace from the latest disconnect, and a new client clears the close intent. Add App.Options.startup_timeout (15 s by default) for first-connection waiting, restarted by open/openWithBrowser and extended by page or bridge requests, plus Running.requestExit() as the webui_exit equivalent. --- README.md | 30 +++- docs/PURE_ZIG_REFACTOR.md | 13 +- examples/public-tls/main.zig | 3 + src/app.zig | 323 +++++++++++++++++++++++++++++++---- 4 files changed, 322 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 62cae55..0d0866f 100644 --- a/README.md +++ b/README.md @@ -138,14 +138,28 @@ should run `zig build test-bridge`, which fails when Node is unavailable. `Window.evalAll` returns owned results; call `deinit` on them after consuming every per-client outcome. -`Running.wait()` normally returns once every client is gone. Backend close -requests bypass its 1.5-second reconnect grace period; other disconnects should -receive that grace so reloads and `Window.setContent()` can reconnect. -Known limitation: close intent is currently sticky across windows, so closing -one window can bypass a later unrelated window's reload grace. An initial wait -with no clients also lacks a startup/stop completion condition; explicitly use -`Window.waitForConnection()` with a timeout before waiting for client shutdown. -Bridge retries do not extend this grace period or restart a stopped backend. +`Running.wait()` evaluates every window independently and returns, then stops +the application, once no window remains active. A connected window is active. +After its last client leaves, a backend `Window.close()` finishes that window +immediately, while any other disconnect gets a 1.5-second reconnect grace +measured from the latest disconnect, so reloads and `Window.setContent()` can +reconnect. A close intent belongs to its own window and is cleared when that +window authenticates a new client, so closing one window never shortens another +window's reload grace. + +Until any window connects, every window waits up to +`App.Options.startup_timeout` (15 seconds by default, like upstream +`webui_set_timeout`). After that, a never-connected window keeps the wait alive +only if `Window.open()` or `Window.openWithBrowser()` was called for it, timed +from that call like upstream `webui_show`. Each page or bridge request extends +first-connection waiting by five seconds for slow pages. Set +`startup_timeout = null` to wait indefinitely for the first client; zero and +negative durations return `error.InvalidStartupTimeout` from `start`. +`Running.requestExit()` sends a backend close to every page and makes the +active wait return, like `webui_exit()`; it is safe from handlers and other +threads. Only one `wait()` may run at a time; another returns +`error.AlreadyWaiting`, and `wait()` after `stop()` returns immediately. +Bridge retries do not extend the grace period or restart a stopped backend. Longer outages can recover only while the application keeps its server running. `Window.open()` discovers the best installed browser and launches it as a diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index 399685e..b7fc5fe 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -52,7 +52,6 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Content composition | Embedded HTML plus disk assets and a custom override/fallthrough handler cannot be composed; resource-only replacement currently replaces page content and navigates. | | Entry and custom routing | No configured local entry file; custom handlers lack virtual-directory index probing. Physical directory index precedence and 302 redirects are fixed in this rescan. | | Live window lifecycle | `createWindow` rejects after start; no independent destroy/unregister/reclaim while other windows run. `close` is not a replacement for `destroy`. | -| Wait lifecycle | Sticky cross-window close intent can bypass an unrelated reconnect grace; initial no-client `wait` lacks startup/stop completion. These are source findings, not newly reproduced regressions. | | Callback metadata | Named `Call` does not expose its binding name or click-vs-explicit-call origin; callbacks cannot access a bounded snapshot of the connection's cookies. | | Firefox app mode | No generated Firefox app profile/userChrome.css or managed preference setup. Existing caller-profile support and explicit high-contrast error do not implement those capabilities. | | Browser discovery | Registered Windows Chromium using `chrome.exe` and macOS bundles outside the fixed application directories lack upstream discovery paths. | @@ -60,6 +59,12 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Native page integration | No upstream page-title-to-host synchronization; no GTK engine-level navigation-policy interception independent of a live bridge. | | Default presentation | No upstream default fallback favicon. F5/context-menu/DevTools policy differences are intentional UI-policy candidates, not proof of missing protocol support. | +Closed after the rescan, each with focused tests in the same change: + +| Area | Resolution | +|---|---| +| Wait lifecycle | Close intent, reconnect grace, and first-connection waiting are per window; `startup_timeout` and `Running.requestExit()` give the initial wait upstream's timeout and `webui_exit` completion. Tests: `wait state tracks startup, activity, reconnect grace, and close per window`, `wait keeps per-window close intent and honours exit requests`. | + Borrowed custom HTTP handlers can await work before returning through `std.Io`; there is no owned post-return HTTP reply handle. This is an explicit Zig task composition alternative, not the same ownership contract as `webui_return_http`. @@ -428,8 +433,8 @@ Mappings with an explicit remaining gap are partial, not parity-complete: | `webui_set_icon()`, `webui_set_icon_file()` | `Window.setIcon()` copies inline data and MIME type; `Window.setIconFile()` loads a supported image file as the window favicon. | | `webui_set_profile()` | Caller-managed profiles and isolated owned Chromium profile leaves are supported. Partial: Firefox managed app profiles, chrome suppression and preference setup remain absent. | | `webui_set_proxy()` | `App.WindowOptions.proxy_server` is copied and passed as one Chromium-family `--proxy-server` argument. Unsupported browsers return an explicit error. | -| `webui_wait()`, `webui_wait_async()` | `Running.wait()` used directly or through `std.Io` concurrency. A 1.5-second reconnect grace is implemented, but sticky cross-window close intent and initial no-client wait completion remain known lifecycle gaps. | -| `webui_close()`, `webui_destroy()`, `webui_exit()`, `webui_clean()` | `Window.close()`, `Running.stop()`, and `App.deinit()`. Partial: there is no independent running-window destroy/reclaim. | +| `webui_wait()`, `webui_wait_async()` | `Running.wait()` used directly or through `std.Io` concurrency. Each window is evaluated independently: a backend close ends only that window, other disconnects get a 1.5-second grace from the latest disconnect, and a new client clears that window's close intent. Never-connected windows wait for the startup timeout. A second concurrent waiter returns `error.AlreadyWaiting`. | +| `webui_close()`, `webui_destroy()`, `webui_exit()`, `webui_clean()` | `Window.close()`, `Running.requestExit()`, `Running.stop()`, and `App.deinit()`. `requestExit()` closes every page and ends the active wait from any thread. Partial: there is no independent running-window destroy/reclaim. | | `webui_set_context()`, `webui_get_context()` | Binding and event-handler `user_data`. | | `webui_bind()` | `Window.bind(io, name, handler, user_data)` supports explicit calls, DOM clicks, and runtime replacement. `Window.onEvent(io, handler, user_data)` updates event handling. `CMD_ADD_ID` pushes new registrations and authentication replays current state; in-flight work keeps its handler snapshot. | | `webui_get_count()`, `webui_get_size()`, `webui_get_size_at()` | `Call.arguments.len` and `Call.bytes(index).len`. | @@ -437,7 +442,7 @@ Mappings with an explicit remaining gap are partial, not parity-complete: | `webui_return_string()`, `webui_return_int()`, `webui_return_float()`, `webui_return_bool()` | `Call.reply()`, `Call.replyInt()`, `Call.replyFloat()`, and `Call.replyBool()`. | | `webui_set_config(asynchronous_response)` | `Call.deferReply()` transfers the response to a bounded, owned, one-shot `PendingReply`. | | `webui_set_config(ui_event_blocking)`, `webui_set_event_blocking()` | `WindowOptions.event_mode` and `Window.setEventMode()` select serial or bounded concurrent binding and event execution. | -| `webui_set_config(show_wait_connection)`, `webui_set_timeout()` | `Window.open()` remains non-blocking; callers explicitly compose it with `Window.waitForConnection(io, timeout)`. | +| `webui_set_config(show_wait_connection)`, `webui_set_timeout()` | `App.Options.startup_timeout` (15 seconds by default, null for no limit) bounds first-connection waiting in `Running.wait()`, restarted by `Window.open()`/`openWithBrowser()` and extended five seconds per page or bridge request, like upstream. `Window.open()` stays non-blocking; compose it with `Window.waitForConnection(io, timeout)` for an explicit blocking show. | | `webui_run()`, `webui_script()` | `Window.run()` and `Window.eval()`. | | `webui_run_client()`, `webui_script_client()` | `Client.run()` and `Client.eval()`. | | `webui_close_client()`, `webui_navigate_client()`, `webui_send_raw_client()` | `Client.close()`, `Client.navigate()`, and `Client.sendRaw()`. | diff --git a/examples/public-tls/main.zig b/examples/public-tls/main.zig index 2a3780c..952cd50 100644 --- a/examples/public-tls/main.zig +++ b/examples/public-tls/main.zig @@ -16,6 +16,9 @@ pub fn main(init: std.process.Init) !void { .public = true, .use_cookies = true, .tls = .{ .certificate_pem = certificate, .private_key_pem = private_key }, + // Remote visitors arrive whenever they like; never time out the + // first connection. + .startup_timeout = null, }); defer app.deinit(); const window = try app.createWindow(.{ .content = .{ .html = "Caller-provided TLS

Pure Zig WebUI over TLS

" } }); diff --git a/src/app.zig b/src/app.zig index 93fe554..c871601 100644 --- a/src/app.zig +++ b/src/app.zig @@ -17,6 +17,11 @@ const cookie_name = "webui_auth"; /// backend navigation before `Running.wait()` treats the application as /// closed. Matches upstream `WEBUI_RELOAD_TIMEOUT`. const reconnect_grace: std.Io.Duration = .fromMilliseconds(1500); +/// Extra first-connection time after the latest page or bridge request, +/// matching upstream's five-second "wait more" startup extension. +const startup_activity_grace: std.Io.Duration = .fromSeconds(5); +const no_activity = std.math.minInt(i64); +const wait_forever = std.math.maxInt(i64); const favicon_link = ""; const directory_reload_script = "location.reload();"; @@ -583,8 +588,22 @@ const WindowState = struct { /// Whether registry updates should notify active browser peers. running: std.atomic.Value(bool) = .init(false), /// Set by backend `close` calls so `Running.wait()` skips the - /// reconnect grace period. + /// reconnect grace period. Cleared when a new client authenticates, so a + /// close intent never outlives this window's reconnection. close_requested: std.atomic.Value(bool) = .init(false), + /// Set when a client first authenticates during the current run. + ever_connected: std.atomic.Value(bool) = .init(false), + /// Awake-clock milliseconds of the latest HTTP request for this window. + last_request_ms: std.atomic.Value(i64) = .init(no_activity), + /// Awake-clock milliseconds of the first-connection deadline; + /// `wait_forever` when the startup timeout is disabled. + startup_deadline_ms: std.atomic.Value(i64) = .init(wait_forever), + /// Set when the app opened or launched a browser for this window, the + /// equivalent of upstream `webui_show`. + shown: std.atomic.Value(bool) = .init(false), + startup_timeout: ?std.Io.Duration = null, + /// Awake-clock milliseconds of the latest client disconnect. + last_disconnect_ms: std.atomic.Value(i64) = .init(no_activity), /// Single-client windows hand their cookie to exactly one client. cookie_issued: std.atomic.Value(bool) = .init(false), clients: std.ArrayList(ConnectedClient) = .empty, @@ -958,9 +977,73 @@ const WindowState = struct { }); self.next_client_id +%= 1; if (self.next_client_id == 0) self.next_client_id = 1; + // Upstream clears `is_closed` on reconnection; a stale backend close + // must not make a later reload of this window skip its grace period. + self.close_requested.store(false, .release); + self.ever_connected.store(true, .release); return .{ .state = self, .client_id = client_id }; } + /// Prepare first-connection tracking when this window starts serving. + fn beginServing(self: *WindowState, io: std.Io, startup_timeout: ?std.Io.Duration) void { + self.close_requested.store(false, .release); + self.cookie_issued.store(false, .release); + self.ever_connected.store(false, .release); + self.last_request_ms.store(no_activity, .release); + self.last_disconnect_ms.store(no_activity, .release); + self.shown.store(false, .release); + self.startup_timeout = startup_timeout; + self.restartStartup(io); + } + + fn restartStartup(self: *WindowState, io: std.Io) void { + const timeout = self.startup_timeout orelse { + self.startup_deadline_ms.store(wait_forever, .release); + return; + }; + const now = std.Io.Clock.Timestamp.now(io, .awake).raw.toMilliseconds(); + self.startup_deadline_ms.store(now +| timeout.toMilliseconds(), .release); + } + + /// Record a browser open or launch. Like upstream `webui_show`, the + /// startup timeout of a not-yet-connected window restarts here. + fn markShown(self: *WindowState, io: std.Io) void { + self.shown.store(true, .release); + if (!self.ever_connected.load(.acquire)) self.restartStartup(io); + } + + fn recordRequest(self: *WindowState, io: std.Io) void { + const now = std.Io.Clock.Timestamp.now(io, .awake); + self.last_request_ms.store(now.raw.toMilliseconds(), .release); + } + + /// Whether this window keeps `Running.wait()` alive at `now`. + /// `any_connected` reports whether any window has connected this run: + /// until then every window waits for the startup timeout, so manually + /// opened URLs keep working; afterwards only shown windows do. + fn keepsWaiting( + self: *WindowState, + io: std.Io, + now: std.Io.Clock.Timestamp, + any_connected: bool, + ) bool { + if (self.hasClients(io)) return true; + if (self.close_requested.load(.acquire)) return false; + const now_ms = now.raw.toMilliseconds(); + if (!self.ever_connected.load(.acquire)) { + const last = self.last_request_ms.load(.acquire); + if (last != no_activity and + now_ms -| last < startup_activity_grace.toMilliseconds()) + return true; + if (any_connected and !self.shown.load(.acquire)) return false; + return now_ms < self.startup_deadline_ms.load(.acquire); + } + // Each disconnect restarts the grace, so a quick reload followed by + // a real close is still measured from the final disconnect. + const last = self.last_disconnect_ms.load(.acquire); + return last != no_activity and now_ms -| last < reconnect_grace.toMilliseconds(); + } + fn client(self: *WindowState, connection: *Linsang.Connection) ?Client { self.mutex.lockUncancelable(connection.io); defer self.mutex.unlock(connection.io); @@ -1014,6 +1097,10 @@ const WindowState = struct { const index = self.clientIndexByKey(@intFromPtr(connection)) orelse return null; var disconnected_client = self.clients.swapRemove(index); + self.last_disconnect_ms.store( + std.Io.Clock.Timestamp.now(connection.io, .awake).raw.toMilliseconds(), + .release, + ); if (disconnected_client.multi) |*multi| multi.deinit(self.gpa); disconnected_client.retired_eval_ids.deinit(self.gpa); disconnected_client.peer.deinit(); @@ -1986,6 +2073,7 @@ pub const Window = struct { return error.ExplicitBrowserRequired; const page_url = try self.url(running, self.state.gpa); defer self.state.gpa.free(page_url); + self.state.markShown(io); try browser.openUrl(self.state.gpa, io, page_url); } @@ -2001,6 +2089,7 @@ pub const Window = struct { const page_url = try self.url(running, self.state.gpa); defer self.state.gpa.free(page_url); const controls = self.state.browserControls(running.inner.io); + self.state.markShown(running.inner.io); return running.app.launchBrowser( running.inner.io, self.state, @@ -2252,7 +2341,10 @@ pub const App = struct { managed_browsers: std.ArrayList(ManagedBrowser) = .empty, browser_mutex: std.Io.Mutex = .init, started: bool = false, - ever_connected: std.atomic.Value(bool) = .init(false), + /// Set by `Running.requestExit()` from any thread or handler. + exit_requested: std.atomic.Value(bool) = .init(false), + /// Guards the per-window reconnect state owned by one `Running.wait()`. + waiting: std.atomic.Value(bool) = .init(false), unauthenticated_connections: std.atomic.Value(usize) = .init(0), upgrades: std.ArrayList(Upgrade) = .empty, upgrade_mutex: std.Io.Mutex = .init, @@ -2320,6 +2412,10 @@ pub const App = struct { /// Null disables monitoring. A positive duration recursively polls /// directory content and reloads connected clients after changes. folder_monitor_interval: ?std.Io.Duration = null, + /// How long `Running.wait()` keeps a never-connected window alive, + /// extended by five seconds after each page or bridge request. Null + /// waits indefinitely, like upstream `webui_set_timeout(0)`. + startup_timeout: ?std.Io.Duration = .fromSeconds(15), logger: ?Logger = null, logger_user_data: ?*anyopaque = null, limits: Limits = .{}, @@ -2497,12 +2593,10 @@ pub const App = struct { } else break; } } - self.ever_connected.store(false, .release); + self.exit_requested.store(false, .release); self.unauthenticated_connections.store(0, .release); - for (self.windows.items) |window| { - window.close_requested.store(false, .release); - window.cookie_issued.store(false, .release); - } + for (self.windows.items) |window| + window.beginServing(io, self.options.startup_timeout); self.server = Linsang.Server.init(self.gpa, .{ .address = self.options.address, .port = self.options.port, @@ -2540,6 +2634,9 @@ pub const App = struct { if (self.options.folder_monitor_interval) |interval| if (interval.nanoseconds <= 0) return error.InvalidFolderMonitorInterval; + if (self.options.startup_timeout) |timeout| + if (timeout.nanoseconds <= 0) + return error.InvalidStartupTimeout; const address = std.Io.net.IpAddress.parse( self.options.address, self.options.port, @@ -2694,12 +2791,6 @@ pub const App = struct { if (window.hasClients(io)) return true; return false; } - - fn closeRequested(self: *const App) bool { - for (self.windows.items) |window| - if (window.close_requested.load(.acquire)) return true; - return false; - } }; pub const Running = struct { @@ -2727,31 +2818,51 @@ pub const Running = struct { ); } + /// Ask the active `wait()` to stop the application, like upstream + /// `webui_exit()`. Connected pages receive a backend close first. Safe to + /// call from any thread or handler; it never blocks on `wait()` itself. + pub fn requestExit(self: *const Running) void { + if (self.stopped) return; + for (self.app.windows.items) |window| + _ = window.broadcast(self.inner.io, .close, "") catch |err| + window.log(.warn, "Exit close notification failed: {}", .{err}); + self.app.exit_requested.store(true, .release); + } + + /// Block until every window is finished, then stop the application. + /// + /// Each window is evaluated independently. A connected window keeps the + /// wait alive. After its last client leaves, a backend close ends that + /// window immediately; other disconnects get the 1.5-second reconnect + /// grace so reloads and content replacement survive. Until any window + /// connects, every window waits up to `Options.startup_timeout`. After + /// that, a never-connected window keeps the wait alive only if it was + /// opened or launched, timed from that call like upstream `webui_show`. + /// A page or bridge request extends first-connection waiting by five + /// seconds. `requestExit()` ends the wait. pub fn wait(self: *Running) !void { + if (self.stopped) return; + const app = self.app; + if (app.waiting.swap(true, .acq_rel)) return error.AlreadyWaiting; + defer app.waiting.store(false, .release); + const io = self.inner.io; // ponytail: polling is enough for UI shutdown; use an event if latency // below 10 ms becomes meaningful. - const io = self.inner.io; - // A refresh or a backend navigation disconnects the page briefly, so - // an empty application only counts as closed after the reconnect - // grace period, matching upstream. A backend `close` ends the wait - // immediately. - var deadline: ?std.Io.Clock.Timestamp = null; - while (true) { - try std.Io.sleep(io, .fromMilliseconds(10), .awake); - if (!self.app.ever_connected.load(.acquire)) continue; - if (self.app.hasClients(io)) { - deadline = null; - continue; + while (!app.exit_requested.load(.acquire)) { + const now = std.Io.Clock.Timestamp.now(io, .awake); + var any_connected = false; + for (app.windows.items) |window| { + if (window.ever_connected.load(.acquire)) any_connected = true; } - if (self.app.closeRequested()) break; - if (deadline) |limit| { - if (limit.compare(.lte, .now(io, .awake))) break; - } else { - deadline = .fromNow(io, .{ - .clock = .awake, - .raw = reconnect_grace, - }); + var active = false; + for (app.windows.items) |window| { + if (window.keepsWaiting(io, now, any_connected)) { + active = true; + break; + } } + if (!active) break; + try std.Io.sleep(io, .fromMilliseconds(10), .awake); } try self.stop(); } @@ -3171,6 +3282,7 @@ fn onRequest( }; const window = resolved.window; const io = app.server_io orelse return failResponse(response); + window.recordRequest(io); window.content_mutex.lockSharedUncancelable(io); defer window.content_mutex.unlockShared(io); if (std.mem.eql(u8, resolved.resource, "_webui_ws_connect")) { @@ -3390,7 +3502,6 @@ fn onMessage( }; app.authenticatedUpgrade(connection.io, @intFromPtr(connection)); if (new_client) |client| { - app.ever_connected.store(true, .release); std.debug.assert(authenticated == null); window.applyGeometry(connection) catch |err| window.log(.warn, "Browser geometry update failed: {}", .{err}); @@ -3565,6 +3676,148 @@ fn requireSocketIntegration() !void { if (@import("builtin").os.tag == .windows) return error.SkipZigTest; } +test "wait state tracks startup, activity, reconnect grace, and close per window" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var app = App.init(gpa, .{}); + defer app.deinit(); + const window = try app.createWindow(.{ .content = .{ .html = "wait" } }); + const state = window.state; + const later = struct { + fn at(base: std.Io.Clock.Timestamp, milliseconds: i64) std.Io.Clock.Timestamp { + return base.addDuration(.{ + .clock = .awake, + .raw = .fromMilliseconds(milliseconds), + }); + } + }.at; + + state.beginServing(io, .fromMilliseconds(100)); + const base = std.Io.Clock.Timestamp.now(io, .awake); + try std.testing.expect(state.keepsWaiting(io, base, false)); + try std.testing.expect(!state.keepsWaiting(io, later(base, 200), false)); + // Once another window connected, only an opened window keeps waiting, + // and opening it restarts its startup timeout. + try std.testing.expect(!state.keepsWaiting(io, base, true)); + state.markShown(io); + try std.testing.expect(state.shown.load(.acquire)); + try std.testing.expect(state.keepsWaiting(io, base, true)); + try std.testing.expect(!state.keepsWaiting(io, later(base, 5000), true)); + // A page or bridge request extends first-connection waiting by 5 s. + state.last_request_ms.store(later(base, 150).raw.toMilliseconds(), .release); + try std.testing.expect(state.keepsWaiting(io, later(base, 200), false)); + try std.testing.expect(state.keepsWaiting(io, later(base, 5100), true)); + try std.testing.expect(!state.keepsWaiting(io, later(base, 5200), false)); + + // After a connection, a plain disconnect gets exactly the reload grace, + // measured from the latest disconnect. + state.ever_connected.store(true, .release); + state.last_disconnect_ms.store(base.raw.toMilliseconds(), .release); + try std.testing.expect(state.keepsWaiting(io, base, true)); + try std.testing.expect(state.keepsWaiting(io, later(base, 1499), true)); + try std.testing.expect(!state.keepsWaiting(io, later(base, 1500), true)); + state.last_disconnect_ms.store(later(base, 1400).raw.toMilliseconds(), .release); + try std.testing.expect(state.keepsWaiting(io, later(base, 2800), true)); + try std.testing.expect(!state.keepsWaiting(io, later(base, 2900), true)); + // A backend close ends the window without any grace. + state.close_requested.store(true, .release); + try std.testing.expect(!state.keepsWaiting(io, base, true)); + + // Null startup timeout waits indefinitely for the first client, and a + // new run forgets the previous close intent and connection. + state.beginServing(io, null); + try std.testing.expect(!state.close_requested.load(.acquire)); + try std.testing.expect(!state.ever_connected.load(.acquire)); + try std.testing.expect(!state.shown.load(.acquire)); + try std.testing.expect(state.keepsWaiting(io, later(base, 60 * 60 * 1000), false)); + try std.testing.expect(!state.keepsWaiting(io, base, true)); + + var invalid = App.init(gpa, .{ .startup_timeout = .zero }); + defer invalid.deinit(); + _ = try invalid.createWindow(.{ .content = .{ .html = "invalid" } }); + try std.testing.expectError(error.InvalidStartupTimeout, invalid.start(io)); +} + +test "wait keeps per-window close intent and honours exit requests" { + try requireSocketIntegration(); + const gpa = std.testing.allocator; + var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); + defer threaded.deinit(); + const io = threaded.io(); + var response_buffer: [256]u8 = undefined; + + { + var app = App.init(gpa, .{}); + defer app.deinit(); + const first = try app.createWindow(.{ .content = .{ .html = "first" } }); + const second = try app.createWindow(.{ .content = .{ .html = "second" } }); + var running = try app.start(io); + defer running.stop() catch {}; + const first_stream = try connectTestWebSocket(running.inner.address, io, &first.state.capability); + defer first_stream.close(io); + try std.testing.expect(try authenticateTestClient(first_stream, io, gpa, first.state.token, &first.state.capability, &response_buffer)); + const second_stream = try connectTestWebSocket(running.inner.address, io, &second.state.capability); + defer second_stream.close(io); + try std.testing.expect(try authenticateTestClient(second_stream, io, gpa, second.state.token, &second.state.capability, &response_buffer)); + + var waiting = io.async(Running.wait, .{&running}); + defer waiting.cancel(io) catch {}; + for (0..1000) |_| { + if (app.waiting.load(.acquire)) break; + try std.Io.sleep(io, .fromMilliseconds(1), .awake); + } + try std.testing.expectError(error.AlreadyWaiting, running.wait()); + + // Close the first window, then reload the second one. The first + // window's close intent must not end the second window's grace. + try std.testing.expectEqual(@as(usize, 1), try first.close(io)); + const close = try protocol.decode(try readServerFrame(first_stream, io, &response_buffer)); + try std.testing.expectEqual(protocol.Command.close, close.header.command); + try first_stream.shutdown(io, .both); + try second_stream.shutdown(io, .both); + for (0..200) |_| { + if (!first.isShown(io) and !second.isShown(io)) break; + try std.Io.sleep(io, .fromMilliseconds(1), .awake); + } + try std.Io.sleep(io, .fromMilliseconds(100), .awake); + const reloaded = try connectTestWebSocket(running.inner.address, io, &second.state.capability); + defer reloaded.close(io); + try std.testing.expect(try authenticateTestClient(reloaded, io, gpa, second.state.token, &second.state.capability, &response_buffer)); + try std.testing.expect(second.isShown(io)); + + const started = std.Io.Clock.Timestamp.now(io, .awake); + try reloaded.shutdown(io, .both); + try waiting.await(io); + const elapsed = started.untilNow(io).raw.toMilliseconds(); + try std.testing.expect(elapsed >= reconnect_grace.toMilliseconds() - 50); + } + + { + var app = App.init(gpa, .{ .startup_timeout = null }); + defer app.deinit(); + _ = try app.createWindow(.{ .content = .{ .html = "never connected" } }); + var running = try app.start(io); + defer running.stop() catch {}; + var waiting = io.async(Running.wait, .{&running}); + defer waiting.cancel(io) catch {}; + try std.Io.sleep(io, .fromMilliseconds(50), .awake); + running.requestExit(); + try waiting.await(io); + } + + { + var app = App.init(gpa, .{ .startup_timeout = .fromMilliseconds(50) }); + defer app.deinit(); + _ = try app.createWindow(.{ .content = .{ .html = "startup timeout" } }); + var running = try app.start(io); + defer running.stop() catch {}; + const started = std.Io.Clock.Timestamp.now(io, .awake); + try running.wait(); + try std.testing.expect(started.untilNow(io).raw.toMilliseconds() < 1000); + try running.wait(); + } +} + test "application logger receives level, message, and user data" { const gpa = std.testing.allocator; var capture: LoggerCapture = .{}; @@ -6326,7 +6579,7 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" { )); try unauthenticated.shutdown(io, .both); try std.Io.sleep(io, .fromMilliseconds(20), .awake); - try std.testing.expect(!app.ever_connected.load(.acquire)); + try std.testing.expect(!window.state.ever_connected.load(.acquire)); } const client = try connectTestWebSocket( From 7c469cdc55b2bcdbbd9c84a65db7d0954d3ce600 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 20:27:05 +0800 Subject: [PATCH 05/36] feat(app): expose call name, origin, and connection cookies Call now carries the matched binding name and whether it came from an explicit call or a DOM click. Call and Event expose the Cookie header of the client's WebSocket upgrade, like upstream webui_event_t.cookies. The header is copied per connection and bounded by the new Limits.max_cookie_size; larger upgrades are answered with 431. --- README.md | 10 ++ docs/PURE_ZIG_REFACTOR.md | 3 +- src/app.zig | 335 +++++++++++++++++++++++++++++++++----- src/root.zig | 1 + 4 files changed, 305 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 0d0866f..e839d31 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,16 @@ Non-conflicting binding names also expose `webui.(...)`; core and inherite properties are never overwritten, and `webui.call(name, ...)` always remains available. +Inside a binding handler, `Call.name` is the binding name that matched and +`Call.origin` is `.call` for an explicit JavaScript call or `.click` for a DOM +click on the element with that ID; click handlers have no arguments and their +reply is not sent. `Call.cookies` and `Event.cookies` hold the raw `Cookie` +header the client sent with its WebSocket upgrade, like upstream +`webui_event_t.cookies`; `Call.cookie(name)` and `Event.cookie(name)` return one +value. These slices are valid only for the handler duration. Upgrades whose +`Cookie` header exceeds `Limits.max_cookie_size` (8 KiB by default) are +answered with `431` instead of being truncated. + For a click, the general event handler runs before the named binding, using the same registration snapshot and scheduled task in both event modes. diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index b7fc5fe..7ca6b85 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -52,7 +52,6 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Content composition | Embedded HTML plus disk assets and a custom override/fallthrough handler cannot be composed; resource-only replacement currently replaces page content and navigates. | | Entry and custom routing | No configured local entry file; custom handlers lack virtual-directory index probing. Physical directory index precedence and 302 redirects are fixed in this rescan. | | Live window lifecycle | `createWindow` rejects after start; no independent destroy/unregister/reclaim while other windows run. `close` is not a replacement for `destroy`. | -| Callback metadata | Named `Call` does not expose its binding name or click-vs-explicit-call origin; callbacks cannot access a bounded snapshot of the connection's cookies. | | Firefox app mode | No generated Firefox app profile/userChrome.css or managed preference setup. Existing caller-profile support and explicit high-contrast error do not implement those capabilities. | | Browser discovery | Registered Windows Chromium using `chrome.exe` and macOS bundles outside the fixed application directories lack upstream discovery paths. | | Native interaction | Missing GTK custom drag/edge resize, Windows draggable-region setup and resizable frameless host behavior, and Cocoa frameless background movement. | @@ -64,6 +63,7 @@ Closed after the rescan, each with focused tests in the same change: | Area | Resolution | |---|---| | Wait lifecycle | Close intent, reconnect grace, and first-connection waiting are per window; `startup_timeout` and `Running.requestExit()` give the initial wait upstream's timeout and `webui_exit` completion. Tests: `wait state tracks startup, activity, reconnect grace, and close per window`, `wait keeps per-window close intent and honours exit requests`. | +| Callback metadata | `Call.name`, `Call.origin` (`.call` or `.click`), and `Call.cookies`/`Event.cookies` with `cookie(name)` expose the binding name, call origin, and the upgrade's `Cookie` header, copied per connection under `Limits.max_cookie_size` (oversized upgrades answer `431`). Tests: `calls and events expose binding name, origin, and bounded cookies`, `cookie values parse from raw headers`, `upgrade admission owns only accepted connections and removes every state`. | Borrowed custom HTTP handlers can await work before returning through `std.Io`; there is no owned post-return HTTP reply handle. This is an explicit Zig task @@ -335,6 +335,7 @@ protocol input never panics. | `window.show(content)` | Set initial content and call `window.open()`; use `window.setContent()` while running. | | `window.bind()` / `binding()` | `window.bind(io, name, handler, user_data)` | | `Event.get*At()` | `Call.string/int/float/bool/bytes(index)` | +| `Event.element`, `Event.event_type`, `Event.cookies` | `Call.name`, `Call.origin`, `Call.cookies`/`Call.cookie(name)`; `Event.data`, `Event.kind`, `Event.cookies` for event handlers. | | `Event.return*()` | `Call.reply*()` | | `window.run()` | `Window.eval()` | | `Event.runClient()` | `Call.client.eval()` | diff --git a/src/app.zig b/src/app.zig index c871601..b18ce4e 100644 --- a/src/app.zig +++ b/src/app.zig @@ -41,9 +41,13 @@ pub const Limits = struct { max_script_size: usize = 256 << 10, /// Maximum output captured from a `Runtime` interpreter per request. max_runtime_output: usize = 16 << 20, + /// Maximum `Cookie` header retained per connection for callbacks. + /// Larger WebSocket upgrades are answered with 431. + max_cookie_size: usize = 8 << 10, fn validate(self: Limits) !void { - if (self.max_runtime_output == 0) return error.InvalidLimits; + if (self.max_runtime_output == 0 or self.max_cookie_size == 0) + return error.InvalidLimits; const max_payload = self.max_ws_message_size -| protocol.header_len; if (self.max_connections == 0 or self.max_unauthenticated_connections == 0 or @@ -128,8 +132,40 @@ pub const Event = struct { /// Element ID for clicks, URL for navigation, and empty for lifecycle /// events. The slice is only valid for the duration of the handler. data: []const u8 = "", + /// Raw `Cookie` header the client sent with its WebSocket upgrade, like + /// upstream `webui_event_t.cookies`. Valid for the handler duration. + cookies: []const u8 = "", + + /// Value of one cookie from `cookies`, or null when absent. + pub fn cookie(self: *const Event, name: []const u8) ?[]const u8 { + return cookieValue(self.cookies, name); + } +}; + +/// How a binding handler was reached. +pub const CallOrigin = enum { + /// An explicit JavaScript call, such as `webui.call(name, ...)`. + call, + /// A DOM click on an element whose ID is the binding name. + click, }; +/// Parse one cookie from a raw `Cookie` header value. +fn cookieValue(header: []const u8, name: []const u8) ?[]const u8 { + var pairs = std.mem.splitScalar(u8, header, ';'); + while (pairs.next()) |pair| { + const trimmed = std.mem.trim(u8, pair, " \t"); + const separator = std.mem.indexOfScalar(u8, trimmed, '=') orelse + continue; + if (std.mem.eql( + u8, + std.mem.trim(u8, trimmed[0..separator], " \t"), + name, + )) return std.mem.trim(u8, trimmed[separator + 1 ..], " \t"); + } + return null; +} + pub const ResourceHandler = *const fn ( path: []const u8, request: *const Request, @@ -616,6 +652,8 @@ const WindowState = struct { next: ?*HandlerTask = null, target: Client, data: []u8, + /// Owned snapshot of the connection's `Cookie` header. + cookies: []u8, call: ?struct { header: protocol.Header, binding: Binding } = null, kind: EventKind = .click, click_binding: ?Binding = null, @@ -1160,10 +1198,14 @@ const WindowState = struct { header: protocol.Header, binding_value: Binding, arguments: []const []const u8, + cookies: []const u8, ) void { var call: Call = .{ .gpa = self.gpa, .client = target, + .name = binding_value.name, + .origin = .call, + .cookies = cookies, .arguments = arguments, .io = io, .reply_header = header, @@ -1181,18 +1223,20 @@ const WindowState = struct { try io.checkCancel(); if (task.call) |call| { const decoded = protocol.decodeCall(task.data) catch return; - self.invokeCall(io, task.target, call.header, call.binding, decoded.slice()); + self.invokeCall(io, task.target, call.header, call.binding, decoded.slice(), task.cookies); } else { self.invokeEvent(io, .{ .kind = task.kind, .client = task.target, .data = task.data, + .cookies = task.cookies, }, task.click_binding, task.registered); } } fn destroyHandler(self: *WindowState, io: std.Io, task: *HandlerTask) void { self.gpa.free(task.data); + self.gpa.free(task.cookies); self.gpa.destroy(task); self.releaseEvent(io); } @@ -1235,18 +1279,22 @@ const WindowState = struct { binding_value: Binding, decoded: *const protocol.CallPayload, payload: []const u8, + cookies: []const u8, ) !void { _ = decoded; try self.reserveEvent(io); errdefer self.releaseEvent(io); const task = try self.gpa.create(HandlerTask); errdefer self.gpa.destroy(task); + const data = try self.gpa.dupe(u8, payload); + errdefer self.gpa.free(data); task.* = .{ .target = target, - .data = try self.gpa.dupe(u8, payload), + .data = data, + .cookies = try self.gpa.dupe(u8, cookies), .call = .{ .header = header, .binding = binding_value }, }; - errdefer self.gpa.free(task.data); + errdefer self.gpa.free(task.cookies); try self.scheduleHandler(io, task); } @@ -1269,6 +1317,9 @@ const WindowState = struct { var call: Call = .{ .gpa = self.gpa, .client = event.client, + .name = binding_value.name, + .origin = .click, + .cookies = event.cookies, .io = io, .arguments = &.{}, }; @@ -1291,14 +1342,17 @@ const WindowState = struct { errdefer self.releaseEvent(io); const task = try self.gpa.create(HandlerTask); errdefer self.gpa.destroy(task); + const data = try self.gpa.dupe(u8, event.data); + errdefer self.gpa.free(data); task.* = .{ .target = event.client, - .data = try self.gpa.dupe(u8, event.data), + .data = data, + .cookies = try self.gpa.dupe(u8, event.cookies), .kind = event.kind, .click_binding = click_binding, .registered = registered, }; - errdefer self.gpa.free(task.data); + errdefer self.gpa.free(task.cookies); try self.scheduleHandler(io, task); } @@ -1646,6 +1700,13 @@ pub const PendingReply = struct { pub const Call = struct { gpa: std.mem.Allocator, client: Client, + /// Binding name that selected this handler; for clicks, the element ID. + /// Valid for the handler duration. + name: []const u8 = "", + origin: CallOrigin = .call, + /// Raw `Cookie` header from the client's WebSocket upgrade, bounded by + /// `Limits.max_cookie_size`. Valid for the handler duration. + cookies: []const u8 = "", arguments: []const []const u8, response: std.ArrayList(u8) = .empty, io: ?std.Io = null, @@ -1657,6 +1718,11 @@ pub const Call = struct { self.response.deinit(self.gpa); } + /// Value of one cookie from `cookies`, or null when absent. + pub fn cookie(self: *const Call, name: []const u8) ?[]const u8 { + return cookieValue(self.cookies, name); + } + pub fn bytes(self: *const Call, index: usize) ![]const u8 { if (index >= self.arguments.len) return error.MissingArgument; return self.arguments[index]; @@ -2353,19 +2419,45 @@ pub const App = struct { key: usize, window: *WindowState, authenticated: bool = false, + /// Owned copy of the upgrade request's `Cookie` header. + cookies: []u8, }; - fn admitUpgrade(self: *App, io: std.Io, key: usize, window: *WindowState) !void { + fn admitUpgrade( + self: *App, + io: std.Io, + key: usize, + window: *WindowState, + cookies: []const u8, + ) !void { + if (cookies.len > self.options.limits.max_cookie_size) + return error.CookieTooLarge; + const owned = try self.gpa.dupe(u8, cookies); + errdefer self.gpa.free(owned); self.upgrade_mutex.lockUncancelable(io); defer self.upgrade_mutex.unlock(io); if (self.upgrades.items.len >= self.options.limits.max_connections or self.unauthenticated_connections.load(.acquire) >= self.options.limits.max_unauthenticated_connections) return error.ClientLimitReached; - try self.upgrades.append(self.gpa, .{ .key = key, .window = window }); + try self.upgrades.append(self.gpa, .{ + .key = key, + .window = window, + .cookies = owned, + }); _ = self.unauthenticated_connections.fetchAdd(1, .acq_rel); } + /// Cookies of one upgraded connection. Only that connection's callbacks + /// remove its record, so the slice stays valid for the calling callback. + fn upgradeCookies(self: *App, io: std.Io, key: usize) []const u8 { + self.upgrade_mutex.lockUncancelable(io); + defer self.upgrade_mutex.unlock(io); + for (self.upgrades.items) |upgrade| + if (upgrade.key == key) return upgrade.cookies; + return ""; + } + fn authorizedWindow(self: *App, io: std.Io, key: usize) ?*WindowState { self.upgrade_mutex.lockUncancelable(io); defer self.upgrade_mutex.unlock(io); @@ -2386,7 +2478,8 @@ pub const App = struct { } } - fn removeUpgrade(self: *App, io: std.Io, key: usize) ?*WindowState { + /// Remove one upgrade record; the caller frees its `cookies`. + fn removeUpgrade(self: *App, io: std.Io, key: usize) ?Upgrade { self.upgrade_mutex.lockUncancelable(io); defer self.upgrade_mutex.unlock(io); for (self.upgrades.items, 0..) |upgrade, index| { @@ -2396,7 +2489,7 @@ pub const App = struct { const previous = self.unauthenticated_connections.fetchSub(1, .acq_rel); std.debug.assert(previous > 0); } - return upgrade.window; + return upgrade; } // Rejected opens, including allocation failure, never owned admission. return null; @@ -2946,22 +3039,7 @@ fn originAllowed( } fn requestCookie(request: *const Linsang.Request, name: []const u8) ?[]const u8 { - var pairs = std.mem.splitScalar( - u8, - request.header("cookie") orelse return null, - ';', - ); - while (pairs.next()) |pair| { - const trimmed = std.mem.trim(u8, pair, " \t"); - const separator = std.mem.indexOfScalar(u8, trimmed, '=') orelse - continue; - if (std.mem.eql( - u8, - std.mem.trim(u8, trimmed[0..separator], " \t"), - name, - )) return std.mem.trim(u8, trimmed[separator + 1 ..], " \t"); - } - return null; + return cookieValue(request.header("cookie") orelse return null, name); } fn cookieAllowed( @@ -3292,6 +3370,12 @@ fn onRequest( response.status = .forbidden; return .respond; } + if (request.header("cookie")) |cookies| { + if (cookies.len > window.limits.max_cookie_size) { + response.status = @enumFromInt(431); + return .respond; + } + } return .upgrade; } const admitted = cookieAdmit(app, window, request, response) catch @@ -3432,13 +3516,19 @@ fn onOpen(connection: *Linsang.Connection, user_data: ?*anyopaque) void { .raw = .fromSeconds(5), })); // Linsang guarantees req remains valid through this callback. Copy only - // stable identity: no request/header/path slice survives the callback. + // stable identity and a bounded cookie copy: no request/header/path slice + // survives the callback. const resolved = route(app, connection.req.path) orelse { connection.wsClose(.policy_violation, ""); return; }; - app.admitUpgrade(connection.io, @intFromPtr(connection), resolved.window) catch |err| { - connection.wsClose(if (err == error.ClientLimitReached) @enumFromInt(1013) else .internal_error, ""); + const cookies = connection.req.header("cookie") orelse ""; + app.admitUpgrade(connection.io, @intFromPtr(connection), resolved.window, cookies) catch |err| { + connection.wsClose(switch (err) { + error.ClientLimitReached => @enumFromInt(1013), + error.CookieTooLarge => .policy_violation, + else => .internal_error, + }, ""); }; } @@ -3508,6 +3598,7 @@ fn onMessage( window.dispatchEvent(connection.io, .{ .kind = .connected, .client = client, + .cookies = app.upgradeCookies(connection.io, @intFromPtr(connection)), }) catch |err| window.log(.err, "WebUI event dispatch failed: {}", .{err}); } @@ -3583,6 +3674,7 @@ fn onMessage( binding, &decoded, packet.payload, + app.upgradeCookies(connection.io, @intFromPtr(connection)), ) catch { send(connection, app.gpa, packet.header, "") catch {}; }; @@ -3614,6 +3706,7 @@ fn onMessage( .navigation, .client = client, .data = data, + .cookies = app.upgradeCookies(connection.io, @intFromPtr(connection)), }) catch |err| window.log(.err, "WebUI event dispatch failed: {}", .{err}); }, @@ -3623,11 +3716,14 @@ fn onMessage( fn onClose(connection: *Linsang.Connection, user_data: ?*anyopaque) void { const app = appFrom(user_data); - const window = app.removeUpgrade(connection.io, @intFromPtr(connection)) orelse return; + const upgrade = app.removeUpgrade(connection.io, @intFromPtr(connection)) orelse return; + defer app.gpa.free(upgrade.cookies); + const window = upgrade.window; if (window.disconnected(connection)) |client| { window.dispatchEvent(connection.io, .{ .kind = .disconnected, .client = client, + .cookies = upgrade.cookies, }) catch |err| window.log(.err, "WebUI event dispatch failed: {}", .{err}); } @@ -3818,6 +3914,138 @@ test "wait keeps per-window close intent and honours exit requests" { } } +const MetadataCapture = struct { + name: [32]u8 = undefined, + name_len: usize = 0, + origin: CallOrigin = .call, + session: [32]u8 = undefined, + session_len: usize = 0, + calls: std.atomic.Value(u32) = .init(0), + connected_theme: std.atomic.Value(bool) = .init(false), + disconnected_theme: std.atomic.Value(bool) = .init(false), + + fn handler(call: *Call, user_data: ?*anyopaque) !void { + const capture: *MetadataCapture = @ptrCast(@alignCast(user_data.?)); + @memcpy(capture.name[0..call.name.len], call.name); + capture.name_len = call.name.len; + capture.origin = call.origin; + const session = call.cookie("session") orelse ""; + @memcpy(capture.session[0..session.len], session); + capture.session_len = session.len; + if (call.origin == .call) try call.reply(call.name); + _ = capture.calls.fetchAdd(1, .release); + } + + fn onEvent(event: *const Event, user_data: ?*anyopaque) !void { + const capture: *MetadataCapture = @ptrCast(@alignCast(user_data.?)); + const dark = std.mem.eql(u8, event.cookie("theme") orelse "", "dark"); + switch (event.kind) { + .connected => capture.connected_theme.store(dark, .release), + .disconnected => capture.disconnected_theme.store(dark, .release), + else => {}, + } + } + + fn expect(capture: *MetadataCapture, io: std.Io, calls: u32, name: []const u8, origin: CallOrigin) !void { + for (0..1000) |_| { + if (capture.calls.load(.acquire) >= calls) break; + try std.Io.sleep(io, .fromMilliseconds(1), .awake); + } + try std.testing.expectEqual(calls, capture.calls.load(.acquire)); + try std.testing.expectEqualStrings(name, capture.name[0..capture.name_len]); + try std.testing.expectEqual(origin, capture.origin); + try std.testing.expectEqualStrings("abc", capture.session[0..capture.session_len]); + } +}; + +test "calls and events expose binding name, origin, and bounded cookies" { + try requireSocketIntegration(); + const gpa = std.testing.allocator; + var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); + defer threaded.deinit(); + const io = threaded.io(); + var capture: MetadataCapture = .{}; + var app = App.init(gpa, .{ .limits = .{ .max_cookie_size = 64 } }); + defer app.deinit(); + const window = try app.createWindow(.{ .content = .{ .html = "metadata" } }); + try window.bind(io, "explicit", MetadataCapture.handler, &capture); + try window.bind(io, "button", MetadataCapture.handler, &capture); + try window.onEvent(io, MetadataCapture.onEvent, &capture); + var running = try app.start(io); + defer running.stop() catch {}; + + // Oversized cookie headers are refused before the upgrade. + try std.testing.expectError(error.WebSocketUpgradeFailed, connectTestWebSocketHeaders( + running.inner.address, + io, + &window.state.capability, + "http://localhost", + "Cookie: session=" ++ "x" ** 64 ++ "\r\n", + )); + + const stream = try connectTestWebSocketHeaders( + running.inner.address, + io, + &window.state.capability, + "http://localhost", + "Cookie: session=abc; theme=dark\r\n", + ); + defer stream.close(io); + var wire: [256]u8 = undefined; + try std.testing.expect(try authenticateTestClient(stream, io, gpa, window.state.token, &window.state.capability, &wire)); + for (0..1000) |_| { + if (capture.connected_theme.load(.acquire)) break; + try std.Io.sleep(io, .fromMilliseconds(1), .awake); + } + try std.testing.expect(capture.connected_theme.load(.acquire)); + + var packet: std.ArrayList(u8) = .empty; + defer packet.deinit(gpa); + try protocol.append(&packet, gpa, .{ + .token = window.state.token, + .id = 4, + .command = .call, + }, "explicit\x00\x00"); + try sendClientFrame(stream, io, packet.items); + const reply = try protocol.decode(try readServerFrame(stream, io, &wire)); + try std.testing.expectEqual(protocol.Command.call, reply.header.command); + try std.testing.expectEqualStrings("explicit", reply.payload); + try capture.expect(io, 1, "explicit", .call); + + packet.clearRetainingCapacity(); + try protocol.append(&packet, gpa, .{ + .token = window.state.token, + .command = .click, + }, "button"); + try sendClientFrame(stream, io, packet.items); + try capture.expect(io, 2, "button", .click); + + try stream.shutdown(io, .both); + for (0..1000) |_| { + if (capture.disconnected_theme.load(.acquire)) break; + try std.Io.sleep(io, .fromMilliseconds(1), .awake); + } + try std.testing.expect(capture.disconnected_theme.load(.acquire)); +} + +test "cookie values parse from raw headers" { + try std.testing.expectEqualStrings("abc", cookieValue("session=abc; theme=dark", "session").?); + try std.testing.expectEqualStrings("dark", cookieValue(" session = abc ;theme= dark ", "theme").?); + try std.testing.expectEqualStrings("", cookieValue("empty=; other=1", "empty").?); + try std.testing.expect(cookieValue("sessionid=1; flag", "session") == null); + try std.testing.expect(cookieValue("", "session") == null); + const call: Call = .{ + .gpa = std.testing.allocator, + .client = undefined, + .arguments = &.{}, + .cookies = "a=1; b=2", + }; + try std.testing.expectEqualStrings("2", call.cookie("b").?); + try std.testing.expectEqual(CallOrigin.call, call.origin); + const event: Event = .{ .kind = .click, .client = undefined, .cookies = "a=1" }; + try std.testing.expectEqualStrings("1", event.cookie("a").?); +} + test "application logger receives level, message, and user data" { const gpa = std.testing.allocator; var capture: LoggerCapture = .{}; @@ -4570,9 +4798,6 @@ fn connectTestWebSocketOriginCookie( origin: []const u8, cookie: ?[]const u8, ) !std.Io.net.Stream { - const stream = try address.connect(io, .{ .mode = .stream }); - errdefer stream.close(io); - var request: [512]u8 = undefined; var cookie_buffer: [cookie_name.len + cookie_len + 12]u8 = undefined; const cookie_header = if (cookie) |value| try std.fmt.bufPrint( @@ -4582,6 +4807,19 @@ fn connectTestWebSocketOriginCookie( ) else ""; + return connectTestWebSocketHeaders(address, io, capability, origin, cookie_header); +} +/// Upgrade with caller-provided extra header lines, each ending in CRLF. +fn connectTestWebSocketHeaders( + address: std.Io.net.IpAddress, + io: std.Io, + capability: []const u8, + origin: []const u8, + cookie_header: []const u8, +) !std.Io.net.Stream { + const stream = try address.connect(io, .{ .mode = .stream }); + errdefer stream.close(io); + var request: [1024]u8 = undefined; try writeAll( stream, io, @@ -7686,17 +7924,28 @@ test "upgrade admission owns only accepted connections and removes every state" defer app.deinit(); const first = try app.createWindow(.{ .content = .{ .html = "first" } }); const second = try app.createWindow(.{ .content = .{ .html = "second" } }); - try app.admitUpgrade(io, 1, first.state); - try std.testing.expectError(error.ClientLimitReached, app.admitUpgrade(io, 2, second.state)); - try std.testing.expect(app.removeUpgrade(io, 2) == null); + const removedWindow = struct { + fn take(owner: *App, key: usize) ?*WindowState { + const upgrade = owner.removeUpgrade(std.testing.io, key) orelse return null; + owner.gpa.free(upgrade.cookies); + return upgrade.window; + } + }.take; + try app.admitUpgrade(io, 1, first.state, "session=one; theme=dark"); + try std.testing.expectError(error.ClientLimitReached, app.admitUpgrade(io, 2, second.state, "")); + try std.testing.expect(removedWindow(&app, 2) == null); try std.testing.expect(app.authorizedWindow(io, 1) == first.state); + try std.testing.expectEqualStrings("session=one; theme=dark", app.upgradeCookies(io, 1)); + try std.testing.expectEqualStrings("", app.upgradeCookies(io, 2)); app.authenticatedUpgrade(io, 1); app.authenticatedUpgrade(io, 1); - try app.admitUpgrade(io, 2, second.state); - try std.testing.expect(app.removeUpgrade(io, 1) == first.state); - try std.testing.expect(app.removeUpgrade(io, 2) == second.state); - try app.admitUpgrade(io, 3, first.state); - _ = app.removeUpgrade(io, 3); + try app.admitUpgrade(io, 2, second.state, ""); + try std.testing.expect(removedWindow(&app, 1) == first.state); + try std.testing.expect(removedWindow(&app, 2) == second.state); + const oversized = [_]u8{'a'} ** ((Limits{}).max_cookie_size + 1); + try std.testing.expectError(error.CookieTooLarge, app.admitUpgrade(io, 3, first.state, &oversized)); + try app.admitUpgrade(io, 3, first.state, oversized[0 .. oversized.len - 1]); + _ = removedWindow(&app, 3); try std.testing.expectEqual(@as(usize, 0), app.unauthenticated_connections.load(.acquire)); } @@ -7823,8 +8072,8 @@ fn upgradeAllocationFailures(gpa: std.mem.Allocator) !void { var app = App.init(gpa, .{}); defer app.deinit(); const window = try app.createWindow(.{ .content = .{ .html = "admission allocation" } }); - try app.admitUpgrade(std.testing.io, 1, window.state); - defer _ = app.removeUpgrade(std.testing.io, 1); + try app.admitUpgrade(std.testing.io, 1, window.state, "session=allocation"); + defer if (app.removeUpgrade(std.testing.io, 1)) |upgrade| gpa.free(upgrade.cookies); } test "upgrade allocation failures do not retain admission ownership" { diff --git a/src/root.zig b/src/root.zig index 9049274..1fdf585 100644 --- a/src/root.zig +++ b/src/root.zig @@ -7,6 +7,7 @@ pub const Window = @import("app.zig").Window; pub const Client = @import("app.zig").Client; pub const Running = @import("app.zig").Running; pub const Call = @import("app.zig").Call; +pub const CallOrigin = @import("app.zig").CallOrigin; pub const PendingReply = @import("app.zig").PendingReply; pub const Handler = @import("app.zig").Handler; pub const Logger = @import("app.zig").Logger; From 3ec7851d0711da9e9230b4076db9ffc472663c07 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 20:32:06 +0800 Subject: [PATCH 06/36] feat(app): serve the upstream default favicon as a fallback favicon.ico and favicon.svg now resolve the custom icon, then a readable directory file, then the built-in default SVG, with favicon.ico redirecting to favicon.svg like upstream. The origin root serves the same default for pages that declare no icon link. --- README.md | 6 +++ docs/PURE_ZIG_REFACTOR.md | 3 +- src/app.zig | 94 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e839d31..92fbdd8 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,12 @@ Use `Window.setIcon(io, data, mime_type)` for in-memory favicon data or files. Embedded HTML receives a relative favicon link automatically. Directory and custom pages can reference `favicon.ico` relative to the window capability root. +Like upstream, `favicon.ico` and `favicon.svg` resolve to the custom icon, then +a readable file of that name in directory content, then a built-in default: +`favicon.ico` redirects with `302` to `favicon.svg`, which serves the default +SVG. The origin-root `/favicon.ico` and `/favicon.svg` that browsers request for +pages without an icon link also serve that default. Custom handlers keep full +control of their own favicon paths. `Window.setContent(&running, content)` prepares and installs new content, then navigates every connected client to it and returns the number notified. An diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index 7ca6b85..c726c21 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -56,7 +56,7 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Browser discovery | Registered Windows Chromium using `chrome.exe` and macOS bundles outside the fixed application directories lack upstream discovery paths. | | Native interaction | Missing GTK custom drag/edge resize, Windows draggable-region setup and resizable frameless host behavior, and Cocoa frameless background movement. | | Native page integration | No upstream page-title-to-host synchronization; no GTK engine-level navigation-policy interception independent of a live bridge. | -| Default presentation | No upstream default fallback favicon. F5/context-menu/DevTools policy differences are intentional UI-policy candidates, not proof of missing protocol support. | +| Default presentation | F5/context-menu/DevTools policy differences are intentional UI-policy candidates, not proof of missing protocol support. | Closed after the rescan, each with focused tests in the same change: @@ -64,6 +64,7 @@ Closed after the rescan, each with focused tests in the same change: |---|---| | Wait lifecycle | Close intent, reconnect grace, and first-connection waiting are per window; `startup_timeout` and `Running.requestExit()` give the initial wait upstream's timeout and `webui_exit` completion. Tests: `wait state tracks startup, activity, reconnect grace, and close per window`, `wait keeps per-window close intent and honours exit requests`. | | Callback metadata | `Call.name`, `Call.origin` (`.call` or `.click`), and `Call.cookies`/`Event.cookies` with `cookie(name)` expose the binding name, call origin, and the upgrade's `Cookie` header, copied per connection under `Limits.max_cookie_size` (oversized upgrades answer `431`). Tests: `calls and events expose binding name, origin, and bounded cookies`, `cookie values parse from raw headers`, `upgrade admission owns only accepted connections and removes every state`. | +| Default favicon | `favicon.ico`/`favicon.svg` resolve custom icon, then a readable directory file, then upstream's default SVG (`.ico` answers `302` to `favicon.svg`), at both the capability root and the origin root. Test: `favicon falls back from custom icon to local file to the default`. | Borrowed custom HTTP handlers can await work before returning through `std.Io`; there is no owned post-return HTTP reply handle. This is an explicit Zig task diff --git a/src/app.zig b/src/app.zig index b18ce4e..2d962f8 100644 --- a/src/app.zig +++ b/src/app.zig @@ -23,6 +23,29 @@ const startup_activity_grace: std.Io.Duration = .fromSeconds(5); const no_activity = std.math.minInt(i64); const wait_forever = std.math.maxInt(i64); const favicon_link = ""; +/// Upstream's default embedded icon: a plain WebUI-blue square. +const default_favicon = + ""; + +/// Answer `favicon.ico` with a relative 302 to `favicon.svg`, and +/// `favicon.svg` with the default icon, matching upstream. +fn writeDefaultFavicon(response: *Response, resource: []const u8) Linsang.Action { + if (std.mem.eql(u8, resource, "favicon.ico")) { + response.setHeader("Location", "favicon.svg") catch + return failResponse(response); + response.status = .found; + return .respond; + } + response.setHeader("Content-Type", "image/svg+xml") catch + return failResponse(response); + response.setHeader("X-Content-Type-Options", "nosniff") catch + return failResponse(response); + response.write(default_favicon) catch return failResponse(response); + return .respond; +} const directory_reload_script = "location.reload();"; pub const Tls = struct { @@ -3347,6 +3370,11 @@ fn onRequest( ) Linsang.Action { const app = appFrom(user_data); var resolved = route(app, request.path) orelse { + // Browsers request /favicon.ico at the origin root for pages that do + // not declare an icon. The default icon is public, constant data. + if (std.mem.eql(u8, request.path, "/favicon.ico") or + std.mem.eql(u8, request.path, "/favicon.svg")) + return writeDefaultFavicon(response, request.path[1..]); response.status = .not_found; return .respond; }; @@ -3395,6 +3423,18 @@ fn onRequest( response.write(icon.data) catch return failResponse(response); return .respond; } + // Like upstream: custom icon, then a local file, then the default. + const local = switch (window.content) { + .html => false, + .directory => |directory| if (directory.dir) |dir| + readableResource(dir, io, resolved.resource) + else + false, + // Custom handlers own their namespace; external pages never + // load icons from this server. + .custom, .external_url => true, + }; + if (!local) return writeDefaultFavicon(response, resolved.resource); } if (std.mem.eql(u8, resolved.resource, "webui.js")) { response.setHeader("Content-Type", "text/javascript; charset=utf-8") catch @@ -4028,6 +4068,60 @@ test "calls and events expose binding name, origin, and bounded cookies" { try std.testing.expect(capture.disconnected_theme.load(.acquire)); } +test "favicon falls back from custom icon to local file to the default" { + try requireSocketIntegration(); + const gpa = std.testing.allocator; + var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); + defer threaded.deinit(); + const io = threaded.io(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDirPath(io, "with-icon"); + try tmp.dir.createDirPath(io, "without-icon"); + try tmp.dir.writeFile(io, .{ .sub_path = "with-icon/favicon.svg", .data = "local" }); + const with_icon = try std.fmt.allocPrint(gpa, ".zig-cache/tmp/{s}/with-icon", .{tmp.sub_path}); + defer gpa.free(with_icon); + const without_icon = try std.fmt.allocPrint(gpa, ".zig-cache/tmp/{s}/without-icon", .{tmp.sub_path}); + defer gpa.free(without_icon); + + var app = App.init(gpa, .{}); + defer app.deinit(); + const html = try app.createWindow(.{ .content = .{ .html = "" } }); + const local = try app.createWindow(.{ .content = .{ .directory = with_icon } }); + const missing = try app.createWindow(.{ .content = .{ .directory = without_icon } }); + var running = try app.start(io); + defer running.stop() catch {}; + + var target: [capability_len + 16]u8 = undefined; + var response: [2048]u8 = undefined; + // Origin-root requests, made for pages without an icon link. + var bytes = try getTestPath(running.inner.address, io, "/favicon.ico", "\r\n\r\n", &response); + try std.testing.expect(std.mem.startsWith(u8, bytes, "HTTP/1.1 302")); + try std.testing.expect(std.mem.indexOf(u8, bytes, "Location: favicon.svg\r\n") != null); + bytes = try getTestPath(running.inner.address, io, "/favicon.svg", "", &response); + try std.testing.expect(std.mem.indexOf(u8, bytes, "Content-Type: image/svg+xml\r\n") != null); + try std.testing.expect(std.mem.endsWith(u8, bytes, default_favicon)); + bytes = try getTestPath(running.inner.address, io, "/favicon.png", "\r\n\r\n", &response); + try std.testing.expect(std.mem.startsWith(u8, bytes, "HTTP/1.1 404")); + + // Capability-scoped requests for windows without a custom icon. + for ([_]Window{ html, missing }) |window| { + bytes = try getTestPath(running.inner.address, io, try std.fmt.bufPrint(&target, "/{s}/favicon.ico", .{window.state.capability}), "\r\n\r\n", &response); + try std.testing.expect(std.mem.startsWith(u8, bytes, "HTTP/1.1 302")); + bytes = try getTestPath(running.inner.address, io, try std.fmt.bufPrint(&target, "/{s}/favicon.svg", .{window.state.capability}), "", &response); + try std.testing.expect(std.mem.endsWith(u8, bytes, default_favicon)); + } + bytes = try getTestPath(running.inner.address, io, try std.fmt.bufPrint(&target, "/{s}/favicon.svg", .{local.state.capability}), "", &response); + try std.testing.expect(std.mem.endsWith(u8, bytes, "local")); + bytes = try getTestPath(running.inner.address, io, try std.fmt.bufPrint(&target, "/{s}/favicon.ico", .{local.state.capability}), "\r\n\r\n", &response); + try std.testing.expect(std.mem.startsWith(u8, bytes, "HTTP/1.1 302")); + + // A custom icon still wins over the default. + try html.setIcon(io, "custom", "image/svg+xml"); + bytes = try getTestPath(running.inner.address, io, try std.fmt.bufPrint(&target, "/{s}/favicon.ico", .{html.state.capability}), "", &response); + try std.testing.expect(std.mem.endsWith(u8, bytes, "custom")); +} + test "cookie values parse from raw headers" { try std.testing.expectEqualStrings("abc", cookieValue("session=abc; theme=dark", "session").?); try std.testing.expectEqualStrings("dark", cookieValue(" session = abc ;theme= dark ", "theme").?); From 57fd7058878b0cc43603e90e6f4daa9a5cfd34ef Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 20:47:11 +0800 Subject: [PATCH 07/36] feat(app): compose html, handler, folder, and entry as site content Content.site combines optional embedded HTML, a resource handler, a root folder, and an entry file, resolved in upstream order: handler, virtual-directory index probing, root HTML, folder files, then the default favicon or 404. A handler declines a path with an empty 404. The entry redirects the root like webui_show(window, "page.html"). Plain custom content gains the same virtual-index probing, and the directory branch is shared with site folders. --- README.md | 29 ++- docs/PURE_ZIG_REFACTOR.md | 8 +- src/app.zig | 449 +++++++++++++++++++++++++++++++------- src/root.zig | 1 + 4 files changed, 402 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 92fbdd8..4c44e4e 100644 --- a/README.md +++ b/README.md @@ -265,8 +265,33 @@ file in this order: `index.html`, `index.htm`, `index.ts`, `index.js`. The redirect preserves the encoded path and query string, so relative assets resolve under the selected directory. Index lookup does not follow symlinks. Custom resources receive borrowed `webui.Request` and `webui.Response` values; -complete the response before the handler returns. Custom handlers do not have -built-in directory-index probing or fallthrough to a disk directory. +complete the response before the handler returns. A handler declines a path by +answering `404` with an empty body, like an upstream file handler returning +`NULL`; WebUI then asks it for `index.html`, `index.htm`, `index.ts`, and +`index.js` below that path and redirects with `302` to the first one it +answers, matching upstream virtual-directory probing. + +Use `.content = .{ .site = .{ ... } }` to compose upstream's embedded HTML, +file handler, root folder, and entry file. Every field is optional, but at +least one of `html`, `handler`, and `directory` is required: + +```zig +.content = .{ .site = .{ + .handler = .{ .handler = apiHandler, .user_data = &state }, + .directory = "ui", + .entry = "pages/main.html", +} }, +``` + +Requests resolve in upstream order: the `handler`, then virtual-index probing +through it, then `html` at the root, then files from `directory` with the usual +directory-index redirects, then the default favicon or `404`. `entry`, like +`webui_show(window, "pages/main.html")`, makes the root redirect to that file +(served by the handler or present in the folder) and replaces the `index.*` +candidates with its file name when probing handler paths. It must be a relative +path without `..`, empty components, `<>?#"`, or reserved bridge names +(`error.InvalidEntry`) and cannot be combined with `html` +(`error.InvalidContent`). Set `.runtime = .deno`, `.node_js`, or `.bun` in `App.WindowOptions` to run served `.js` and `.ts` files through an external interpreter instead of diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index c726c21..32e49c5 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -49,8 +49,6 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Area | Remaining behavior, not covered by existing API mappings | |---|---| -| Content composition | Embedded HTML plus disk assets and a custom override/fallthrough handler cannot be composed; resource-only replacement currently replaces page content and navigates. | -| Entry and custom routing | No configured local entry file; custom handlers lack virtual-directory index probing. Physical directory index precedence and 302 redirects are fixed in this rescan. | | Live window lifecycle | `createWindow` rejects after start; no independent destroy/unregister/reclaim while other windows run. `close` is not a replacement for `destroy`. | | Firefox app mode | No generated Firefox app profile/userChrome.css or managed preference setup. Existing caller-profile support and explicit high-contrast error do not implement those capabilities. | | Browser discovery | Registered Windows Chromium using `chrome.exe` and macOS bundles outside the fixed application directories lack upstream discovery paths. | @@ -65,6 +63,8 @@ Closed after the rescan, each with focused tests in the same change: | Wait lifecycle | Close intent, reconnect grace, and first-connection waiting are per window; `startup_timeout` and `Running.requestExit()` give the initial wait upstream's timeout and `webui_exit` completion. Tests: `wait state tracks startup, activity, reconnect grace, and close per window`, `wait keeps per-window close intent and honours exit requests`. | | Callback metadata | `Call.name`, `Call.origin` (`.call` or `.click`), and `Call.cookies`/`Event.cookies` with `cookie(name)` expose the binding name, call origin, and the upgrade's `Cookie` header, copied per connection under `Limits.max_cookie_size` (oversized upgrades answer `431`). Tests: `calls and events expose binding name, origin, and bounded cookies`, `cookie values parse from raw headers`, `upgrade admission owns only accepted connections and removes every state`. | | Default favicon | `favicon.ico`/`favicon.svg` resolve custom icon, then a readable directory file, then upstream's default SVG (`.ico` answers `302` to `favicon.svg`), at both the capability root and the origin root. Test: `favicon falls back from custom icon to local file to the default`. | +| Content composition | `Content.site` composes optional embedded HTML, a declinable handler, and a root folder in upstream resolution order. Resource-only replacement without navigation remains open. Test: `site content resolves handler, virtual index, html, folder, and entry in upstream order`. | +| Entry and custom routing | `Site.entry` redirects the root to a validated relative entry file, and handlers that decline a path (empty `404`) are probed for the entry name or `index.*` with `302` redirects, for both `.site` and `.custom`. Same test as content composition. | Borrowed custom HTTP handlers can await work before returning through `std.Io`; there is no owned post-return HTTP reply handle. This is an explicit Zig task @@ -416,7 +416,7 @@ Mappings with an explicit remaining gap are partial, not parity-complete: | Upstream API | Zig replacement | |---|---| | `webui_new_window()`, `webui_new_window_id()`, `webui_get_new_window_id()` | `App.createWindow()` and application-owned IDs. Partial: runtime creation remains unsupported. | -| `webui_show()`, `webui_start_server()`, `webui_get_url()` | Initial `Content`, runtime `Window.setContent()`, `App.start()`, `Window.open()`, and `Window.url()`. `Window.open()` launches the best installed browser in app mode and falls back to the OS URL handler, matching upstream `webui_show()` with `AnyBrowser`. | +| `webui_show()`, `webui_start_server()`, `webui_get_url()` | Initial `Content`, runtime `Window.setContent()`, `App.start()`, `Window.open()`, and `Window.url()`. Upstream's string sniffing becomes explicit variants: HTML `.html`, URL `.external_url`, folder `.directory`, and file `.site` with `entry`. `Window.open()` launches the best installed browser in app mode and falls back to the OS URL handler, matching upstream `webui_show()` with `AnyBrowser`. | | `webui_show_client()` | `Client.show()` replaces the window content and navigates only the selected client. | | `webui_is_shown()` | `Window.isShown()` reports whether the window has at least one connected browser client. | | `webui_set_center()` | `App.WindowOptions.center` and `Window.setCenter()` centre the window on the primary display. Upstream reads the monitor geometry natively, which needs GDK on Linux; the browser computes the coordinates instead, so centring applies once a client connects rather than at launch. Centring and an explicit position clear each other. | @@ -455,7 +455,7 @@ Mappings with an explicit remaining gap are partial, not parity-complete: | `webui_set_public()` | `App.Options.public` permits non-loopback listening only with TLS; Origin and explicit connection and protocol limits are enforced. | | `webui_set_tls_certificate()` | `App.Options.tls` accepts caller-provided PEM certificate and private-key bytes. | | `webui_set_port()`, `webui_get_port()`, `webui_get_free_port()` | `App.Options.port`, including `0` for automatic selection, and the running window URL. | -| `webui_set_root_folder()`, `webui_set_file_handler()`, `webui_set_file_handler_window()`, `webui_return_http()` | Initial/runtime `.directory` or `.custom` content and borrowed `Response`. Partial: modes are exclusive; no HTML-plus-assets/custom fallthrough or resource-only replacement. HTTP work may await before callback return, but there is no owned delayed HTTP reply. | +| `webui_set_root_folder()`, `webui_set_file_handler()`, `webui_set_file_handler_window()`, `webui_return_http()` | `.directory`, `.custom`, or composed `.site` content with a borrowed `Response`; an empty `404` declines a path for virtual-index probing and folder fallthrough. Partial: no resource-only replacement without navigation. HTTP work may await before callback return, but there is no owned delayed HTTP reply. | | `webui_get_mime_type()` | Linsang resource handling. | | `webui_encode()`, `webui_decode()`, `webui_malloc()`, `webui_free()`, `webui_memcpy()` | Zig standard library and allocators. | | `webui_get_last_error_number()`, `webui_get_last_error_message()` | Zig error unions. | diff --git a/src/app.zig b/src/app.zig index 2d962f8..036a24a 100644 --- a/src/app.zig +++ b/src/app.zig @@ -210,6 +210,66 @@ pub const Content = union(enum) { custom: CustomResource, /// HTTP(S) page opened directly. The page must load `Window.bridgeUrl`. external_url: []const u8, + /// Upstream's composed window: embedded HTML, a file handler, a root + /// folder, and an entry file, each optional. + site: Site, +}; + +/// Composed content resolved in upstream order: `handler` first, then +/// virtual-directory index probing through `handler`, then `html` at the +/// root, then `directory`, then the default favicon or 404. +pub const Site = struct { + /// HTML served at the capability root when `handler` declines it. + /// Mutually exclusive with `entry`. + html: ?[]const u8 = null, + /// Consulted first for every resource. Answering 404 with an empty body + /// declines the path, like an upstream file handler returning NULL. + handler: ?CustomResource = null, + /// Folder serving resources the handler and `html` leave unanswered, + /// like upstream `webui_set_root_folder`. + directory: ?[]const u8 = null, + /// Relative file the root redirects to, like upstream + /// `webui_show(window, "page.html")`. Its file name also replaces the + /// `index.*` candidates when probing handler directories. + entry: ?[]const u8 = null, +}; + +const max_entry_size = 1024; + +fn validEntry(entry: []const u8) bool { + return entry.len <= max_entry_size and safeSubPath(entry) and + std.mem.indexOfAny(u8, entry, "<>?#\"") == null and + !std.mem.eql(u8, entry, "webui.js") and + !std.mem.eql(u8, entry, "_webui_ws_connect"); +} + +const StoredSite = struct { + html: ?[]u8 = null, + handler: ?CustomResource = null, + directory: ?*DirectoryContent = null, + entry: ?[]u8 = null, + + fn init(gpa: std.mem.Allocator, site: Site) !StoredSite { + if (site.html == null and site.handler == null and site.directory == null) + return error.InvalidContent; + if (site.entry) |entry| { + if (site.html != null) return error.InvalidContent; + if (!validEntry(entry)) return error.InvalidEntry; + } + var result: StoredSite = .{ .handler = site.handler }; + errdefer result.deinit(gpa); + if (site.html) |html| result.html = try gpa.dupe(u8, html); + if (site.directory) |path| result.directory = try DirectoryContent.init(gpa, path); + if (site.entry) |entry| result.entry = try gpa.dupe(u8, entry); + return result; + } + + fn deinit(self: *StoredSite, gpa: std.mem.Allocator) void { + if (self.html) |html| gpa.free(html); + if (self.directory) |directory| directory.release(); + if (self.entry) |entry| gpa.free(entry); + self.* = undefined; + } }; const Binding = struct { @@ -536,6 +596,7 @@ const StoredContent = union(enum) { directory: *DirectoryContent, custom: CustomResource, external_url: []u8, + site: StoredSite, fn init(gpa: std.mem.Allocator, content: Content) !StoredContent { return switch (content) { @@ -548,6 +609,7 @@ const StoredContent = union(enum) { try validateExternalUrl(url); break :blk .{ .external_url = try gpa.dupe(u8, url) }; }, + .site => |site| .{ .site = try StoredSite.init(gpa, site) }, }; } @@ -557,22 +619,26 @@ const StoredContent = union(enum) { .directory => |directory| directory.release(), .custom => {}, .external_url => |url| gpa.free(url), + .site => |*site| site.deinit(gpa), } self.* = undefined; } + /// The folder this content serves from, if any. + fn folder(self: StoredContent) ?*DirectoryContent { + return switch (self) { + .directory => |directory| directory, + .site => |site| site.directory, + else => null, + }; + } + fn openDirectory(self: *StoredContent, io: std.Io) !void { - switch (self.*) { - .directory => |directory| try directory.open(io), - else => {}, - } + if (self.folder()) |directory| try directory.open(io); } fn closeDirectory(self: *StoredContent) void { - switch (self.*) { - .directory => |directory| directory.close(), - else => {}, - } + if (self.folder()) |directory| directory.close(); } }; @@ -731,16 +797,9 @@ const WindowState = struct { ) ?struct { directory: *DirectoryContent, revision: u64 } { self.content_mutex.lockSharedUncancelable(io); defer self.content_mutex.unlockShared(io); - return switch (self.content) { - .directory => |directory| blk: { - directory.retain(); - break :blk .{ - .directory = directory, - .revision = self.content_revision, - }; - }, - else => null, - }; + const directory = self.content.folder() orelse return null; + directory.retain(); + return .{ .directory = directory, .revision = self.content_revision }; } fn hasContentRevision( @@ -3433,6 +3492,8 @@ fn onRequest( // Custom handlers own their namespace; external pages never // load icons from this server. .custom, .external_url => true, + // Sites consult their handler first and fall back themselves. + .site => true, }; if (!local) return writeDefaultFavicon(response, resolved.resource); } @@ -3460,67 +3521,17 @@ fn onRequest( response.status = .not_found; break :blk .respond; }, - .directory => |directory| blk: { - const dir = directory.dir orelse break :blk failResponse(response); - if (directoryIndex(dir, io, resolved.resource)) |entry| { - // Keep the encoded request path: decoding it into Location - // would turn literal %, # or ? filenames into URL syntax. - const location = std.fmt.allocPrint(app.gpa, "{s}{s}{s}{s}{s}", .{ - request.path, - if (std.mem.endsWith(u8, request.path, "/")) "" else "/", - entry, - if (request.query.len == 0) "" else "?", - request.query, - }) catch break :blk failResponse(response); - defer app.gpa.free(location); - response.setHeader("Location", location) catch break :blk failResponse(response); - response.status = .found; - break :blk .respond; - } - if (window.runtime) |runtime| { - if (runtimeScript( - dir, - io, - resolved.resource, - )) |sub_path| { - interpretScript( - window, - io, - runtime, - dir, - sub_path, - request.query, - response, - ) catch |err| { - window.log( - .warn, - "Runtime interpretation of {s} failed: {}", - .{ sub_path, err }, - ); - break :blk failResponse(response); - }; - break :blk .respond; - } - const extension = std.fs.path.extension(resolved.resource); - if (std.mem.eql(u8, extension, ".js") or std.mem.eql(u8, extension, ".ts")) { - // An unreadable, symlinked or nonregular script must never - // fall through to a source response. - response.status = .not_found; - break :blk .respond; - } - } - const static_request = app.gpa.create(StaticRequest) catch - break :blk failResponse(response); - static_request.* = .{ .directory = directory, .path_storage = path_storage }; - owns_path = false; - directory.retain(); - break :blk .{ .files = .{ - .dir = dir, - .canonical_path = resolved.resource, - .on_complete = releaseStaticDirectory, - .user_data = static_request, - } }; - }, + .directory => |directory| serveDirectory( + app, + window, + io, + request, + response, + directory, + resolved.resource, + path_storage, + &owns_path, + ), .custom => |custom| blk: { custom.handler( resolved.resource, @@ -3528,15 +3539,196 @@ fn onRequest( response, custom.user_data, ) catch break :blk failResponse(response); + if (!declined(response)) break :blk .respond; + if (probeHandlerIndex(app.gpa, custom, request, resolved.resource, null)) |name| + break :blk redirectBelow(app.gpa, request, response, name); break :blk .respond; }, .external_url => blk: { response.status = .not_found; break :blk .respond; }, + .site => |site| serveSite( + app, + window, + io, + request, + response, + site, + resolved.resource, + path_storage, + &owns_path, + ), }; } +fn notFound(response: *Linsang.Response) Linsang.Action { + response.reset(); + response.status = .not_found; + return .respond; +} + +/// A resource handler declines a path by answering 404 without a body. +fn declined(response: *const Linsang.Response) bool { + return response.status == .not_found and response.body_buf.items.len == 0; +} + +fn isFavicon(resource: []const u8) bool { + return std.mem.eql(u8, resource, "favicon.ico") or + std.mem.eql(u8, resource, "favicon.svg"); +} + +/// Redirect to `name` below the requested path, keeping the encoded request +/// path and query: decoding them into Location would turn literal %, # or ? +/// file names into URL syntax. +fn redirectBelow( + gpa: std.mem.Allocator, + request: *const Linsang.Request, + response: *Linsang.Response, + name: []const u8, +) Linsang.Action { + var location: std.ArrayList(u8) = .empty; + defer location.deinit(gpa); + appendRedirect(&location, gpa, request, name) catch return failResponse(response); + response.setHeader("Location", location.items) catch return failResponse(response); + response.status = .found; + return .respond; +} + +fn appendRedirect( + location: *std.ArrayList(u8), + gpa: std.mem.Allocator, + request: *const Linsang.Request, + name: []const u8, +) !void { + try location.appendSlice(gpa, request.path); + if (!std.mem.endsWith(u8, request.path, "/")) try location.append(gpa, '/'); + for (name) |byte| { + if (std.ascii.isAlphanumeric(byte) or std.mem.indexOfScalar(u8, "-._~/", byte) != null) { + try location.append(gpa, byte); + } else { + try location.print(gpa, "%{X:0>2}", .{byte}); + } + } + if (request.query.len != 0) { + try location.append(gpa, '?'); + try location.appendSlice(gpa, request.query); + } +} + +/// Upstream virtual-directory probing: when a handler declines `resource`, +/// ask it for the entry's file name, or for `index.*` without an entry, +/// below that path. Returns the first name the handler answers. +fn probeHandlerIndex( + gpa: std.mem.Allocator, + custom: CustomResource, + request: *const Linsang.Request, + resource: []const u8, + entry: ?[]const u8, +) ?[]const u8 { + const index_names = [_][]const u8{ "index.html", "index.htm", "index.ts", "index.js" }; + var entry_name = [1][]const u8{std.fs.path.basenamePosix(entry orelse "")}; + const names: []const []const u8 = if (entry != null) &entry_name else &index_names; + const separator = if (resource.len == 0 or std.mem.endsWith(u8, resource, "/")) "" else "/"; + for (names) |name| { + const path = std.mem.concat(gpa, u8, &.{ resource, separator, name }) catch return null; + defer gpa.free(path); + var scratch = Response.init(gpa); + defer scratch.deinit(); + custom.handler(path, request, &scratch, custom.user_data) catch continue; + if (!declined(&scratch)) return name; + } + return null; +} + +fn serveSite( + app: *App, + window: *WindowState, + io: std.Io, + request: *const Linsang.Request, + response: *Linsang.Response, + site: StoredSite, + resource: []const u8, + path_storage: []u8, + owns_path: *bool, +) Linsang.Action { + const root_entry = if (resource.len == 0) site.entry else null; + if (site.handler) |custom| { + custom.handler(root_entry orelse resource, request, response, custom.user_data) catch + return failResponse(response); + if (!declined(response)) { + // Like upstream, a root answered through the entry redirects to + // it instead of serving it under the root URL. + const entry = root_entry orelse return .respond; + response.reset(); + return redirectBelow(app.gpa, request, response, entry); + } + response.reset(); + if (probeHandlerIndex(app.gpa, custom, request, resource, site.entry)) |name| + return redirectBelow(app.gpa, request, response, name); + } + if (resource.len == 0) { + if (site.html) |html| { + response.setHeader("Content-Type", "text/html; charset=utf-8") catch + return failResponse(response); + writeHtml(response, html, window.icon != null) catch + return failResponse(response); + return .respond; + } + } + const directory = site.directory orelse + return if (isFavicon(resource)) writeDefaultFavicon(response, resource) else notFound(response); + const dir = directory.dir orelse return failResponse(response); + if (root_entry) |entry| { + if (!readableResource(dir, io, entry)) return notFound(response); + return redirectBelow(app.gpa, request, response, entry); + } + if (isFavicon(resource) and !readableResource(dir, io, resource)) + return writeDefaultFavicon(response, resource); + return serveDirectory(app, window, io, request, response, directory, resource, path_storage, owns_path); +} + +fn serveDirectory( + app: *App, + window: *WindowState, + io: std.Io, + request: *const Linsang.Request, + response: *Linsang.Response, + directory: *DirectoryContent, + resource: []const u8, + path_storage: []u8, + owns_path: *bool, +) Linsang.Action { + const dir = directory.dir orelse return failResponse(response); + if (directoryIndex(dir, io, resource)) |entry| + return redirectBelow(app.gpa, request, response, entry); + if (window.runtime) |runtime| { + if (runtimeScript(dir, io, resource)) |sub_path| { + interpretScript(window, io, runtime, dir, sub_path, request.query, response) catch |err| { + window.log(.warn, "Runtime interpretation of {s} failed: {}", .{ sub_path, err }); + return failResponse(response); + }; + return .respond; + } + const extension = std.fs.path.extension(resource); + if (std.mem.eql(u8, extension, ".js") or std.mem.eql(u8, extension, ".ts")) { + // An unreadable, symlinked or nonregular script must never fall + // through to a source response. + return notFound(response); + } + } + const static_request = app.gpa.create(StaticRequest) catch return failResponse(response); + static_request.* = .{ .directory = directory, .path_storage = path_storage }; + owns_path.* = false; + directory.retain(); + return .{ .files = .{ + .dir = dir, + .canonical_path = resource, + .on_complete = releaseStaticDirectory, + .user_data = static_request, + } }; +} + fn send( connection: *Linsang.Connection, gpa: std.mem.Allocator, @@ -4122,6 +4314,105 @@ test "favicon falls back from custom icon to local file to the default" { try std.testing.expect(std.mem.endsWith(u8, bytes, "custom")); } +fn siteTestHandler( + path: []const u8, + _: *const Request, + response: *Response, + user_data: ?*anyopaque, +) anyerror!void { + const calls: *std.atomic.Value(usize) = @ptrCast(@alignCast(user_data.?)); + _ = calls.fetchAdd(1, .monotonic); + const answers = [_][2][]const u8{ + .{ "api/data", "handler data" }, + .{ "virtual/index.html", "virtual index" }, + .{ "docs/index.htm", "custom docs" }, + .{ "app page.html", "app page" }, + }; + for (answers) |answer| { + if (std.mem.eql(u8, path, answer[0])) return response.write(answer[1]); + } + response.status = .not_found; +} + +fn expectRedirect(address: std.Io.net.IpAddress, io: std.Io, window: Window, path: []const u8, location: []const u8) !void { + var target: [capability_len + 64]u8 = undefined; + var expected: [capability_len + 96]u8 = undefined; + var response: [1024]u8 = undefined; + const bytes = try getTestPath(address, io, try std.fmt.bufPrint(&target, "/{s}/{s}", .{ window.state.capability, path }), "\r\n\r\n", &response); + try std.testing.expect(std.mem.startsWith(u8, bytes, "HTTP/1.1 302")); + try std.testing.expect(std.mem.indexOf(u8, bytes, try std.fmt.bufPrint(&expected, "Location: /{s}/{s}\r\n", .{ window.state.capability, location })) != null); +} + +fn expectBody(address: std.Io.net.IpAddress, io: std.Io, window: Window, path: []const u8, status: []const u8, body: []const u8) !void { + var target: [capability_len + 64]u8 = undefined; + var response: [2048]u8 = undefined; + const bytes = try getTestPath(address, io, try std.fmt.bufPrint(&target, "/{s}/{s}", .{ window.state.capability, path }), body, &response); + try std.testing.expect(std.mem.startsWith(u8, bytes, status)); + try std.testing.expect(std.mem.indexOf(u8, bytes, body) != null); +} + +test "site content resolves handler, virtual index, html, folder, and entry in upstream order" { + try requireSocketIntegration(); + const gpa = std.testing.allocator; + var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); + defer threaded.deinit(); + const io = threaded.io(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDirPath(io, "site/pages"); + try tmp.dir.writeFile(io, .{ .sub_path = "site/style.css", .data = "body{}" }); + try tmp.dir.writeFile(io, .{ .sub_path = "site/pages/main.html", .data = "main page" }); + const folder = try std.fmt.allocPrint(gpa, ".zig-cache/tmp/{s}/site", .{tmp.sub_path}); + defer gpa.free(folder); + + var calls: std.atomic.Value(usize) = .init(0); + const handler: CustomResource = .{ .handler = siteTestHandler, .user_data = &calls }; + var app = App.init(gpa, .{}); + defer app.deinit(); + try std.testing.expectError(error.InvalidContent, app.createWindow(.{ .content = .{ .site = .{} } })); + try std.testing.expectError(error.InvalidContent, app.createWindow(.{ .content = .{ .site = .{ .html = "x", .entry = "main.html" } } })); + for ([_][]const u8{ "../main.html", "/main.html", "a//b.html", "webui.js", "page?.html", "" }) |entry| { + try std.testing.expectError(error.InvalidEntry, app.createWindow(.{ .content = .{ .site = .{ .directory = folder, .entry = entry } } })); + } + const composed = try app.createWindow(.{ .content = .{ .site = .{ + .html = "root page", + .handler = handler, + .directory = folder, + } } }); + const entry_folder = try app.createWindow(.{ .content = .{ .site = .{ .directory = folder, .entry = "pages/main.html" } } }); + const entry_handler = try app.createWindow(.{ .content = .{ .site = .{ .handler = handler, .entry = "app page.html" } } }); + const custom = try app.createWindow(.{ .content = .{ .custom = handler } }); + var running = try app.start(io); + defer running.stop() catch {}; + const address = running.inner.address; + + // The handler is consulted first, then probed for a virtual index, then + // the root HTML and the folder answer. + try expectBody(address, io, composed, "api/data", "HTTP/1.1 200", "handler data"); + try expectBody(address, io, composed, "", "HTTP/1.1 200", "root page"); + try expectBody(address, io, composed, "style.css", "HTTP/1.1 200", "body{}"); + try expectRedirect(address, io, composed, "virtual", "virtual/index.html"); + try expectRedirect(address, io, composed, "virtual/?q=1", "virtual/index.html?q=1"); + try expectBody(address, io, composed, "favicon.ico", "HTTP/1.1 302", "Location: favicon.svg\r\n"); + try expectBody(address, io, composed, "favicon.svg", "HTTP/1.1 200", default_favicon); + try expectBody(address, io, composed, "missing.txt", "HTTP/1.1 404", "\r\n\r\n"); + + // Entry files redirect the root, from the folder or through the handler. + try expectRedirect(address, io, entry_folder, "", "pages/main.html"); + try expectBody(address, io, entry_folder, "pages/main.html", "HTTP/1.1 200", "main page"); + try expectRedirect(address, io, entry_handler, "", "app%20page.html"); + try expectBody(address, io, entry_handler, "app%20page.html", "HTTP/1.1 200", "app page"); + // With an entry only its file name is probed, never index.*. + calls.store(0, .monotonic); + try expectBody(address, io, entry_handler, "virtual", "HTTP/1.1 404", "\r\n\r\n"); + try std.testing.expectEqual(@as(usize, 2), calls.load(.monotonic)); + + // Plain custom content gains the same virtual-directory probing. + try expectRedirect(address, io, custom, "docs", "docs/index.htm"); + try expectBody(address, io, custom, "docs/index.htm", "HTTP/1.1 200", "custom docs"); + try expectBody(address, io, custom, "nothing", "HTTP/1.1 404", "\r\n\r\n"); +} + test "cookie values parse from raw headers" { try std.testing.expectEqualStrings("abc", cookieValue("session=abc; theme=dark", "session").?); try std.testing.expectEqualStrings("dark", cookieValue(" session = abc ;theme= dark ", "theme").?); diff --git a/src/root.zig b/src/root.zig index 1fdf585..eea107a 100644 --- a/src/root.zig +++ b/src/root.zig @@ -18,6 +18,7 @@ pub const Runtime = @import("app.zig").Runtime; pub const EventHandler = @import("app.zig").EventHandler; pub const EvalResult = @import("app.zig").EvalResult; pub const Content = @import("app.zig").Content; +pub const Site = @import("app.zig").Site; pub const CustomResource = @import("app.zig").CustomResource; pub const ResourceHandler = @import("app.zig").ResourceHandler; pub const Request = @import("app.zig").Request; From d35b7bf4efdb7c48b10da675ea2b61c6c3d214e2 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 20:47:11 +0800 Subject: [PATCH 08/36] feat(app): install replacement content without navigation Window.installContent swaps the served content for later requests and leaves connected pages in place, like changing the root folder or file handler of a shown upstream window. External URLs change the page origin, so either side being external returns NavigationRequired. --- README.md | 7 +++++ docs/PURE_ZIG_REFACTOR.md | 6 ++-- src/app.zig | 65 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4c44e4e..e5fc1a3 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,13 @@ path without `..`, empty components, `<>?#"`, or reserved bridge names (`error.InvalidEntry`) and cannot be combined with `html` (`error.InvalidContent`). +`Window.installContent(&running, content)` replaces content for later requests +without navigating any client, like changing the root folder or file handler +of a shown upstream window. `Window.setContent` replaces and navigates. Because +an external URL changes the page origin, `installContent` returns +`error.NavigationRequired` when either the current or the new content is +`.external_url`. + Set `.runtime = .deno`, `.node_js`, or `.bun` in `App.WindowOptions` to run served `.js` and `.ts` files through an external interpreter instead of sending them to the browser. Directory index selection happens first, so an diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index 32e49c5..edf45c4 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -63,8 +63,8 @@ Closed after the rescan, each with focused tests in the same change: | Wait lifecycle | Close intent, reconnect grace, and first-connection waiting are per window; `startup_timeout` and `Running.requestExit()` give the initial wait upstream's timeout and `webui_exit` completion. Tests: `wait state tracks startup, activity, reconnect grace, and close per window`, `wait keeps per-window close intent and honours exit requests`. | | Callback metadata | `Call.name`, `Call.origin` (`.call` or `.click`), and `Call.cookies`/`Event.cookies` with `cookie(name)` expose the binding name, call origin, and the upgrade's `Cookie` header, copied per connection under `Limits.max_cookie_size` (oversized upgrades answer `431`). Tests: `calls and events expose binding name, origin, and bounded cookies`, `cookie values parse from raw headers`, `upgrade admission owns only accepted connections and removes every state`. | | Default favicon | `favicon.ico`/`favicon.svg` resolve custom icon, then a readable directory file, then upstream's default SVG (`.ico` answers `302` to `favicon.svg`), at both the capability root and the origin root. Test: `favicon falls back from custom icon to local file to the default`. | -| Content composition | `Content.site` composes optional embedded HTML, a declinable handler, and a root folder in upstream resolution order. Resource-only replacement without navigation remains open. Test: `site content resolves handler, virtual index, html, folder, and entry in upstream order`. | -| Entry and custom routing | `Site.entry` redirects the root to a validated relative entry file, and handlers that decline a path (empty `404`) are probed for the entry name or `index.*` with `302` redirects, for both `.site` and `.custom`. Same test as content composition. | +| Content composition | `Content.site` composes optional embedded HTML, a declinable handler, and a root folder in upstream resolution order; `Window.installContent` replaces resources without navigation. Tests: `site content resolves handler, virtual index, html, folder, and entry in upstream order`, `installed content changes resources without navigating clients`. | +| Entry and custom routing | `Site.entry` redirects the root to a validated relative entry file, and handlers that decline a path (empty `404`) are probed for the entry name or `index.*` with `302` redirects, for both `.site` and `.custom`. Same tests as content composition. | Borrowed custom HTTP handlers can await work before returning through `std.Io`; there is no owned post-return HTTP reply handle. This is an explicit Zig task @@ -455,7 +455,7 @@ Mappings with an explicit remaining gap are partial, not parity-complete: | `webui_set_public()` | `App.Options.public` permits non-loopback listening only with TLS; Origin and explicit connection and protocol limits are enforced. | | `webui_set_tls_certificate()` | `App.Options.tls` accepts caller-provided PEM certificate and private-key bytes. | | `webui_set_port()`, `webui_get_port()`, `webui_get_free_port()` | `App.Options.port`, including `0` for automatic selection, and the running window URL. | -| `webui_set_root_folder()`, `webui_set_file_handler()`, `webui_set_file_handler_window()`, `webui_return_http()` | `.directory`, `.custom`, or composed `.site` content with a borrowed `Response`; an empty `404` declines a path for virtual-index probing and folder fallthrough. Partial: no resource-only replacement without navigation. HTTP work may await before callback return, but there is no owned delayed HTTP reply. | +| `webui_set_root_folder()`, `webui_set_file_handler()`, `webui_set_file_handler_window()`, `webui_return_http()` | `.directory`, `.custom`, or composed `.site` content with a borrowed `Response`; an empty `404` declines a path for virtual-index probing and folder fallthrough. `Window.installContent()` swaps resources without navigation. HTTP work may await before callback return, but there is no owned delayed HTTP reply. | | `webui_get_mime_type()` | Linsang resource handling. | | `webui_encode()`, `webui_decode()`, `webui_malloc()`, `webui_free()`, `webui_memcpy()` | Zig standard library and allocators. | | `webui_get_last_error_number()`, `webui_get_last_error_message()` | Zig error unions. | diff --git a/src/app.zig b/src/app.zig index 036a24a..cb0ae7e 100644 --- a/src/app.zig +++ b/src/app.zig @@ -776,6 +776,7 @@ const WindowState = struct { self: *WindowState, io: std.Io, content: Content, + require_hosted: bool, ) !void { var replacement = try StoredContent.init(self.gpa, content); errdefer replacement.deinit(self.gpa); @@ -784,6 +785,8 @@ const WindowState = struct { self.content_mutex.lockUncancelable(io); defer self.content_mutex.unlock(io); + if (require_hosted and self.content == .external_url) + return error.NavigationRequired; var previous = self.content; self.content = replacement; @@ -1952,7 +1955,7 @@ pub const Client = struct { var peer = try self.retainPeer(running.inner.io); defer peer.deinit(); - try self.state.replaceContent(running.inner.io, content); + try self.state.replaceContent(running.inner.io, content, false); const target_url = try (Window{ .state = self.state }).url( running, self.state.gpa, @@ -2169,12 +2172,30 @@ pub const Window = struct { if (!running.app.hasWindow(self.state)) return error.UnknownWindow; if (running.app.options.use_cookies and content == .external_url) return error.ExternalUrlCookiesUnsupported; - try self.state.replaceContent(running.inner.io, content); + try self.state.replaceContent(running.inner.io, content, false); const target_url = try self.url(running, self.state.gpa); defer self.state.gpa.free(target_url); return self.navigate(running.inner.io, target_url); } + /// Replace served content for later requests without navigating any + /// client, like upstream `webui_set_root_folder` or + /// `webui_set_file_handler` on a shown window. Hosted pages keep running + /// and load new resources from the replacement. External URLs change + /// the page origin, so neither side may be `.external_url`; use + /// `setContent` for those (`error.NavigationRequired`). + pub fn installContent( + self: Window, + running: *const Running, + content: Content, + ) !void { + if (running.stopped or !running.app.started) + return error.NotRunning; + if (!running.app.hasWindow(self.state)) return error.UnknownWindow; + if (content == .external_url) return error.NavigationRequired; + try self.state.replaceContent(running.inner.io, content, true); + } + /// Copy favicon data and its HTTP content type into this window. pub fn setIcon( self: Window, @@ -4413,6 +4434,46 @@ test "site content resolves handler, virtual index, html, folder, and entry in u try expectBody(address, io, custom, "nothing", "HTTP/1.1 404", "\r\n\r\n"); } +test "installed content changes resources without navigating clients" { + try requireSocketIntegration(); + const gpa = std.testing.allocator; + var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); + defer threaded.deinit(); + const io = threaded.io(); + var calls: std.atomic.Value(usize) = .init(0); + var app = App.init(gpa, .{}); + defer app.deinit(); + const window = try app.createWindow(.{ .content = .{ .html = "first" } }); + const external = try app.createWindow(.{ .content = .{ .external_url = "https://example.com/" } }); + var capture: MetadataCapture = .{}; + try window.bind(io, "echo", MetadataCapture.handler, &capture); + var running = try app.start(io); + defer running.stop() catch {}; + try std.testing.expectError(error.NavigationRequired, window.installContent(&running, .{ .external_url = "https://example.com/" })); + try std.testing.expectError(error.NavigationRequired, external.installContent(&running, .{ .html = "hosted" })); + + const stream = try connectTestWebSocket(running.inner.address, io, &window.state.capability); + defer stream.close(io); + var wire: [256]u8 = undefined; + try std.testing.expect(try authenticateTestClient(stream, io, gpa, window.state.token, &window.state.capability, &wire)); + _ = try window.waitForConnection(io, .fromSeconds(1)); + try window.installContent(&running, .{ .site = .{ + .html = "second", + .handler = .{ .handler = siteTestHandler, .user_data = &calls }, + } }); + try expectBody(running.inner.address, io, window, "", "HTTP/1.1 200", "second"); + try expectBody(running.inner.address, io, window, "api/data", "HTTP/1.1 200", "handler data"); + + // The next frame answers this call: no navigation was pushed first. + var packet: std.ArrayList(u8) = .empty; + defer packet.deinit(gpa); + try protocol.append(&packet, gpa, .{ .token = window.state.token, .id = 7, .command = .call }, "echo\x00\x00"); + try sendClientFrame(stream, io, packet.items); + const reply = try protocol.decode(try readServerFrame(stream, io, &wire)); + try std.testing.expectEqual(protocol.Command.call, reply.header.command); + try std.testing.expectEqual(@as(u16, 7), reply.header.id); +} + test "cookie values parse from raw headers" { try std.testing.expectEqualStrings("abc", cookieValue("session=abc; theme=dark", "session").?); try std.testing.expectEqualStrings("dark", cookieValue(" session = abc ;theme= dark ", "theme").?); From e329b36f26da75bdf96c7cc9e926cb7364238630 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 20:58:30 +0800 Subject: [PATCH 09/36] test(app): tolerate server-closed sockets and async event handlers Rejected clients may already be disconnected by the server, which macOS reports as an unconnected socket on shutdown. An unbound call is answered without entering the event queue, so the integration test now waits for the queued connect and click handlers instead of reading their flags. --- src/app.zig | 51 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/src/app.zig b/src/app.zig index cb0ae7e..4ab18bf 100644 --- a/src/app.zig +++ b/src/app.zig @@ -4122,8 +4122,8 @@ test "wait keeps per-window close intent and honours exit requests" { try std.testing.expectEqual(@as(usize, 1), try first.close(io)); const close = try protocol.decode(try readServerFrame(first_stream, io, &response_buffer)); try std.testing.expectEqual(protocol.Command.close, close.header.command); - try first_stream.shutdown(io, .both); - try second_stream.shutdown(io, .both); + try disconnectTestStream(first_stream, io); + try disconnectTestStream(second_stream, io); for (0..200) |_| { if (!first.isShown(io) and !second.isShown(io)) break; try std.Io.sleep(io, .fromMilliseconds(1), .awake); @@ -4135,7 +4135,7 @@ test "wait keeps per-window close intent and honours exit requests" { try std.testing.expect(second.isShown(io)); const started = std.Io.Clock.Timestamp.now(io, .awake); - try reloaded.shutdown(io, .both); + try disconnectTestStream(reloaded, io); try waiting.await(io); const elapsed = started.untilNow(io).raw.toMilliseconds(); try std.testing.expect(elapsed >= reconnect_grace.toMilliseconds() - 50); @@ -4273,7 +4273,7 @@ test "calls and events expose binding name, origin, and bounded cookies" { try sendClientFrame(stream, io, packet.items); try capture.expect(io, 2, "button", .click); - try stream.shutdown(io, .both); + try disconnectTestStream(stream, io); for (0..1000) |_| { if (capture.disconnected_theme.load(.acquire)) break; try std.Io.sleep(io, .fromMilliseconds(1), .awake); @@ -4859,8 +4859,17 @@ fn deferredReplyHandler(call: *Call, user_data: ?*anyopaque) !void { capture.ready.store(true, .release); } +/// Close both directions of a test socket. The server may already have +/// closed it, which some platforms report as an unconnected socket. +fn disconnectTestStream(stream: std.Io.net.Stream, io: std.Io) !void { + stream.shutdown(io, .both) catch |err| switch (err) { + error.SocketUnconnected => {}, + else => return err, + }; +} + fn waitForFlag(io: std.Io, flag: *const std.atomic.Value(bool)) !void { - for (0..100) |_| { + for (0..2000) |_| { if (flag.load(.acquire)) return; try std.Io.sleep(io, .fromMilliseconds(1), .awake); } @@ -5505,7 +5514,7 @@ fn exerciseHeartbeat(io: std.Io, scenario: HeartbeatTest) anyerror!void { try std.testing.expectEqual(protocol.Command.call, reply.header.command); try std.testing.expectEqual(@as(u16, 9), reply.header.id); try std.testing.expectEqualStrings("Hello from Zig", reply.payload); - try client.shutdown(io, .both); + try disconnectTestStream(client, io); } try running.stop(); } @@ -6702,8 +6711,8 @@ test "directory monitor reloads changed window only" { )); try std.testing.expectEqualStrings(stable_script, packet.payload); - try first_stream.shutdown(io, .both); - try second_stream.shutdown(io, .both); + try disconnectTestStream(first_stream, io); + try disconnectTestStream(second_stream, io); try running.stop(); try std.testing.expect( app.monitor_tasks.token.load(.acquire) == null, @@ -6759,7 +6768,7 @@ test "window connection waiting observes clients and timeouts" { const immediate = try window.waitForConnection(io, .zero); try std.testing.expectEqual(delayed.id(), immediate.id()); - try stream.shutdown(io, .both); + try disconnectTestStream(stream, io); for (0..100) |_| { if (!delayed.isConnected(io)) break; try std.Io.sleep(io, .fromMilliseconds(1), .awake); @@ -6861,7 +6870,7 @@ test "binding replies can be deferred, bounded, and disconnected" { }, "later\x00\x00"); try sendClientFrame(client, io, packet.items); try waitForFlag(io, &capture.ready); - try client.shutdown(io, .both); + try disconnectTestStream(client, io); for (0..100) |_| { if (!capture.client.?.isConnected(io)) break; try std.Io.sleep(io, .fromMilliseconds(1), .awake); @@ -6969,7 +6978,7 @@ test "cookie authorization guards WebSocket upgrades" { &window.state.capability, &response_payload, )); - try client.shutdown(io, .both); + try disconnectTestStream(client, io); try std.Io.sleep(io, .fromMilliseconds(20), .awake); } @@ -7241,7 +7250,7 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" { "http://external.example", ); defer external_client.close(io); - try external_client.shutdown(io, .both); + try disconnectTestStream(external_client, io); try std.Io.sleep(io, .fromMilliseconds(20), .awake); } @@ -7261,7 +7270,7 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" { "ffffffffffffffffffffffffffffffff", &rejected_response, )); - try unauthenticated.shutdown(io, .both); + try disconnectTestStream(unauthenticated, io); try std.Io.sleep(io, .fromMilliseconds(20), .awake); try std.testing.expect(!window.state.ever_connected.load(.acquire)); } @@ -7399,8 +7408,10 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" { &second_response, )); try std.testing.expectEqual(@as(usize, 0), isolated_reply.payload.len); - try std.testing.expect(secondary_events.connected.load(.acquire)); - try std.testing.expect(secondary_events.clicked.load(.acquire)); + // The unbound call is answered without entering the event queue, so the + // queued connect and click handlers may still be running. + try waitForFlag(io, &secondary_events.connected); + try waitForFlag(io, &secondary_events.clicked); try std.testing.expect(!secondary_events.navigated.load(.acquire)); var eval_buffer: [64]u8 = undefined; @@ -7641,7 +7652,7 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" { std.Io.Duration.fromSeconds(1), }); _ = try readServerFrame(client, io, &response_payload); - try client.shutdown(io, .both); + try disconnectTestStream(client, io); try std.testing.expectError(error.ConnectionClosed, disconnect_future.await(io)); try std.testing.expect(!targeted_client.isConnected(io)); try std.testing.expectError(error.ConnectionClosed, targeted_client.eval( @@ -7655,7 +7666,7 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" { targeted_client.close(io), ); try std.testing.expect(app.hasClients(io)); - try second_client.shutdown(io, .both); + try disconnectTestStream(second_client, io); try running.wait(); try std.testing.expect(primary_events.disconnected.load(.acquire)); try std.testing.expect(secondary_events.disconnected.load(.acquire)); @@ -8083,7 +8094,7 @@ test "multi-client limits, targeting, and disconnect lifecycle" { std.Io.Duration.fromSeconds(1), )); - try first_stream.shutdown(io, .both); + try disconnectTestStream(first_stream, io); var first_disconnected = false; for (0..100) |_| { if (!first.isConnected(io)) { @@ -8134,7 +8145,7 @@ test "multi-client limits, targeting, and disconnect lifecycle" { &second_response, )); try std.testing.expectEqual(protocol.Command.close, second_close.header.command); - try second_stream.shutdown(io, .both); + try disconnectTestStream(second_stream, io); try running.wait(); try std.testing.expect(!window.isShown(io)); } @@ -8249,7 +8260,7 @@ test "runtime registrations replace in-flight handlers and replay racing updates // A real reconnect replays registrations, including one whose installation // races authentication: it must appear in replay or in the subsequent push. - try client.shutdown(io, .both); + try disconnectTestStream(client, io); try waitForFlag(io, &replacement_events.disconnected); const reconnect = try connectTestWebSocket(running.inner.address, io, &window.state.capability); defer reconnect.close(io); From 6b75e476f46c37805856383d21c03e6dc55f488e Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 21:05:29 +0800 Subject: [PATCH 10/36] test(app): wait for client registration before checking isShown The server acknowledges CHECK_TOKEN before registering the client, so tests that just authenticated poll for the registration instead of reading isShown immediately. --- src/app.zig | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app.zig b/src/app.zig index 4ab18bf..9fe7d99 100644 --- a/src/app.zig +++ b/src/app.zig @@ -4132,7 +4132,7 @@ test "wait keeps per-window close intent and honours exit requests" { const reloaded = try connectTestWebSocket(running.inner.address, io, &second.state.capability); defer reloaded.close(io); try std.testing.expect(try authenticateTestClient(reloaded, io, gpa, second.state.token, &second.state.capability, &response_buffer)); - try std.testing.expect(second.isShown(io)); + try waitForShown(second, io); const started = std.Io.Clock.Timestamp.now(io, .awake); try disconnectTestStream(reloaded, io); @@ -4868,6 +4868,16 @@ fn disconnectTestStream(stream: std.Io.net.Stream, io: std.Io) !void { }; } +/// The server answers CHECK_TOKEN before it registers the client, so a +/// test that just authenticated must wait for the registration. +fn waitForShown(window: Window, io: std.Io) !void { + for (0..2000) |_| { + if (window.isShown(io)) return; + try std.Io.sleep(io, .fromMilliseconds(1), .awake); + } + return error.Timeout; +} + fn waitForFlag(io: std.Io, flag: *const std.atomic.Value(bool)) !void { for (0..2000) |_| { if (flag.load(.acquire)) return; @@ -7707,7 +7717,7 @@ test "multi-client limits, targeting, and disconnect lifecycle" { &window.state.capability, &first_response, )); - try std.testing.expect(window.isShown(io)); + try waitForShown(window, io); var packet: std.ArrayList(u8) = .empty; defer packet.deinit(gpa); From baa2d4133e9ab9d2ba9cc549a6de3379fa52b1e7 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 21:25:01 +0800 Subject: [PATCH 11/36] refactor(app): give each window its own directory monitor task Monitors now live in a per-window group so one window's monitor can be cancelled without stopping the others. They are spawned with concurrent before the server starts, so an unavailable worker fails start with an error instead of running the endless poll loop inline. --- src/app.zig | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/app.zig b/src/app.zig index 9fe7d99..327dc00 100644 --- a/src/app.zig +++ b/src/app.zig @@ -712,6 +712,8 @@ const WindowState = struct { /// Whether registry updates should notify active browser peers. running: std.atomic.Value(bool) = .init(false), + /// This window's directory monitor, cancelled on stop. + monitor_task: std.Io.Group = .init, /// Set by backend `close` calls so `Running.wait()` skips the /// reconnect grace period. Cleared when a new client authenticates, so a /// close intent never outlives this window's reconnection. @@ -754,6 +756,7 @@ const WindowState = struct { std.debug.assert(self.pending_replies == 0); std.debug.assert(self.pending_events == 0); std.debug.assert(self.event_tasks.token.load(.acquire) == null); + std.debug.assert(self.monitor_task.token.load(.acquire) == null); self.pending_evals.deinit(self.gpa); for (self.clients.items) |*connected| { if (connected.multi) |*multi| multi.deinit(self.gpa); @@ -2506,7 +2509,6 @@ pub const App = struct { server: ?Linsang.Server = null, server_io: ?std.Io = null, tls_auth: ?Linsang.tls.CertKeyPair = null, - monitor_tasks: std.Io.Group = .init, managed_browsers: std.ArrayList(ManagedBrowser) = .empty, browser_mutex: std.Io.Mutex = .init, started: bool = false, @@ -2662,7 +2664,6 @@ pub const App = struct { std.debug.assert(!self.started); std.debug.assert(self.server_io == null); std.debug.assert(self.tls_auth == null); - std.debug.assert(self.monitor_tasks.token.load(.acquire) == null); std.debug.assert(self.managed_browsers.items.len == 0); self.managed_browsers.deinit(self.gpa); std.debug.assert(self.upgrades.items.len == 0); @@ -2793,6 +2794,15 @@ pub const App = struct { self.unauthenticated_connections.store(0, .release); for (self.windows.items) |window| window.beginServing(io, self.options.startup_timeout); + errdefer for (self.windows.items) |window| window.monitor_task.cancel(io); + if (self.options.folder_monitor_interval) |interval| { + for (self.windows.items) |window| + try window.monitor_task.concurrent(io, monitorDirectory, .{ + window, + io, + interval, + }); + } self.server = Linsang.Server.init(self.gpa, .{ .address = self.options.address, .port = self.options.port, @@ -2814,14 +2824,6 @@ pub const App = struct { window.running.store(false, .release); const inner = try self.server.?.start(io); self.started = true; - if (self.options.folder_monitor_interval) |interval| { - for (self.windows.items) |window| - self.monitor_tasks.async(io, monitorDirectory, .{ - window, - io, - interval, - }); - } return .{ .app = self, .inner = inner }; } @@ -2997,9 +2999,10 @@ pub const Running = struct { pub fn stop(self: *Running) !void { if (self.stopped) return; try self.inner.stop(); - for (self.app.windows.items) |window| + for (self.app.windows.items) |window| { window.running.store(false, .release); - self.app.monitor_tasks.cancel(self.inner.io); + window.monitor_task.cancel(self.inner.io); + } for (self.app.windows.items) |window| window.cancelEvents(self.inner.io); self.app.stopBrowsers(self.inner.io); @@ -6725,7 +6728,10 @@ test "directory monitor reloads changed window only" { try disconnectTestStream(second_stream, io); try running.stop(); try std.testing.expect( - app.monitor_tasks.token.load(.acquire) == null, + first_window.state.monitor_task.token.load(.acquire) == null, + ); + try std.testing.expect( + second_window.state.monitor_task.token.load(.acquire) == null, ); } From c9c1902f68ad7a4672cbd959592e1bf7aac068ec Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 21:25:25 +0800 Subject: [PATCH 12/36] feat(app): create and destroy windows while the app runs createWindow now serves a window at once when called after start, with fresh credentials, its folder, and its own monitor. destroyWindow mirrors webui_destroy: it unregisters the window immediately, notifies connected pages, closes later transports with 1001, and cancels the window's handlers, monitor, and managed browser in the background. Window state is reference counted by the app list, upgrade records, in-flight requests, and deferred replies, and the window registry is guarded by a read-write lock, so a destroyed window is freed only after its last user finishes. Running.stop completes any pending cleanup. Client.window exposes the owning window so handlers can destroy it. --- README.md | 24 +- docs/PURE_ZIG_REFACTOR.md | 7 +- src/app.zig | 515 ++++++++++++++++++++++++++++++++------ 3 files changed, 469 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index e5fc1a3..0e2844d 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,8 @@ compile or link the upstream WebUI C library or CivetWeb. support. The core rewrite is substantial, but full upstream behavioral parity is not -complete. A fresh source audit found gaps in content composition, live window -creation/destruction, Firefox app profiles, and native drag/resize, title and -navigation integration. See the +complete. A fresh source audit found gaps in Firefox app profiles, browser +discovery, and native drag/resize, title and navigation integration. See the [open semantic gaps](docs/PURE_ZIG_REFACTOR.md#open-semantic-gaps) and the [source comparison](docs/UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). @@ -162,6 +161,25 @@ threads. Only one `wait()` may run at a time; another returns Bridge retries do not extend the grace period or restart a stopped backend. Longer outages can recover only while the application keeps its server running. +`App.createWindow()` also works while the app runs, from handlers or other +threads: the window is served at once with its own fresh token, capability, +and cookie, opens its folder, and starts its own folder monitor. +`App.destroyWindow()` is upstream `webui_destroy()`. Before `start` it frees +the window at once. While running it stops routing immediately, so the +window's page, bridge script, and WebSocket upgrades answer `404`. Connected +pages get a backend close, and any later message on their transport closes +it with `1001`. In the background, queued and running handlers of that window +are cancelled, the caller included, so a reply from a handler that destroys +its own window is best effort. Its folder monitor and managed browser stop +too, while the browser profile is kept. The window's memory is freed once its +last connection, request, and deferred reply finish, and `Running.stop()` +completes any cleanup still pending. `Client.window()` returns the window of a +call or event, so a handler can destroy its own window. Other windows keep +running. When no window remains, `Running.wait()` returns. Destroying +an unknown or already destroyed window returns `error.UnknownWindow` without +reading it. A destroyed `Window` handle and its `Client` handles must not be +used again, or concurrently with the destroy call. + `Window.open()` discovers the best installed browser and launches it as a standalone app window, exactly like `Window.openWithBrowser()` with that browser. It returns immediately. When no known browser is installed it hands diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index edf45c4..ba63dde 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -49,7 +49,6 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Area | Remaining behavior, not covered by existing API mappings | |---|---| -| Live window lifecycle | `createWindow` rejects after start; no independent destroy/unregister/reclaim while other windows run. `close` is not a replacement for `destroy`. | | Firefox app mode | No generated Firefox app profile/userChrome.css or managed preference setup. Existing caller-profile support and explicit high-contrast error do not implement those capabilities. | | Browser discovery | Registered Windows Chromium using `chrome.exe` and macOS bundles outside the fixed application directories lack upstream discovery paths. | | Native interaction | Missing GTK custom drag/edge resize, Windows draggable-region setup and resizable frameless host behavior, and Cocoa frameless background movement. | @@ -64,6 +63,7 @@ Closed after the rescan, each with focused tests in the same change: | Callback metadata | `Call.name`, `Call.origin` (`.call` or `.click`), and `Call.cookies`/`Event.cookies` with `cookie(name)` expose the binding name, call origin, and the upgrade's `Cookie` header, copied per connection under `Limits.max_cookie_size` (oversized upgrades answer `431`). Tests: `calls and events expose binding name, origin, and bounded cookies`, `cookie values parse from raw headers`, `upgrade admission owns only accepted connections and removes every state`. | | Default favicon | `favicon.ico`/`favicon.svg` resolve custom icon, then a readable directory file, then upstream's default SVG (`.ico` answers `302` to `favicon.svg`), at both the capability root and the origin root. Test: `favicon falls back from custom icon to local file to the default`. | | Content composition | `Content.site` composes optional embedded HTML, a declinable handler, and a root folder in upstream resolution order; `Window.installContent` replaces resources without navigation. Tests: `site content resolves handler, virtual index, html, folder, and entry in upstream order`, `installed content changes resources without navigating clients`. | +| Live window lifecycle | `App.createWindow` serves windows created while running with fresh credentials, folder, and monitor. `App.destroyWindow` unregisters at once (`404` routing, backend close, `1001` on later messages), then cancels the window's handlers, monitor, and managed browser in the background, and frees it after its last connection, request, and deferred reply. This works from the window's own handlers via `Client.window()`, and `Running.stop` finishes pending cleanup. Tests: `windows are created and destroyed while the app runs`, `destroying a window before start frees it at once`, `upgrade admission owns only accepted connections and removes every state`. | | Entry and custom routing | `Site.entry` redirects the root to a validated relative entry file, and handlers that decline a path (empty `404`) are probed for the entry name or `index.*` with `302` redirects, for both `.site` and `.custom`. Same tests as content composition. | Borrowed custom HTTP handlers can await work before returning through `std.Io`; @@ -337,6 +337,7 @@ protocol input never panics. | `window.bind()` / `binding()` | `window.bind(io, name, handler, user_data)` | | `Event.get*At()` | `Call.string/int/float/bool/bytes(index)` | | `Event.element`, `Event.event_type`, `Event.cookies` | `Call.name`, `Call.origin`, `Call.cookies`/`Call.cookie(name)`; `Event.data`, `Event.kind`, `Event.cookies` for event handlers. | +| `Event.window` | `Call.client.window()` and `Event.client.window()`. | | `Event.return*()` | `Call.reply*()` | | `window.run()` | `Window.eval()` | | `Event.runClient()` | `Call.client.eval()` | @@ -415,7 +416,7 @@ Mappings with an explicit remaining gap are partial, not parity-complete: | Upstream API | Zig replacement | |---|---| -| `webui_new_window()`, `webui_new_window_id()`, `webui_get_new_window_id()` | `App.createWindow()` and application-owned IDs. Partial: runtime creation remains unsupported. | +| `webui_new_window()`, `webui_new_window_id()`, `webui_get_new_window_id()` | `App.createWindow()`, before or after `start`, and application-owned IDs. | | `webui_show()`, `webui_start_server()`, `webui_get_url()` | Initial `Content`, runtime `Window.setContent()`, `App.start()`, `Window.open()`, and `Window.url()`. Upstream's string sniffing becomes explicit variants: HTML `.html`, URL `.external_url`, folder `.directory`, and file `.site` with `entry`. `Window.open()` launches the best installed browser in app mode and falls back to the OS URL handler, matching upstream `webui_show()` with `AnyBrowser`. | | `webui_show_client()` | `Client.show()` replaces the window content and navigates only the selected client. | | `webui_is_shown()` | `Window.isShown()` reports whether the window has at least one connected browser client. | @@ -436,7 +437,7 @@ Mappings with an explicit remaining gap are partial, not parity-complete: | `webui_set_profile()` | Caller-managed profiles and isolated owned Chromium profile leaves are supported. Partial: Firefox managed app profiles, chrome suppression and preference setup remain absent. | | `webui_set_proxy()` | `App.WindowOptions.proxy_server` is copied and passed as one Chromium-family `--proxy-server` argument. Unsupported browsers return an explicit error. | | `webui_wait()`, `webui_wait_async()` | `Running.wait()` used directly or through `std.Io` concurrency. Each window is evaluated independently: a backend close ends only that window, other disconnects get a 1.5-second grace from the latest disconnect, and a new client clears that window's close intent. Never-connected windows wait for the startup timeout. A second concurrent waiter returns `error.AlreadyWaiting`. | -| `webui_close()`, `webui_destroy()`, `webui_exit()`, `webui_clean()` | `Window.close()`, `Running.requestExit()`, `Running.stop()`, and `App.deinit()`. `requestExit()` closes every page and ends the active wait from any thread. Partial: there is no independent running-window destroy/reclaim. | +| `webui_close()`, `webui_destroy()`, `webui_exit()`, `webui_clean()` | `Window.close()`, `App.destroyWindow()`, `Running.requestExit()`, `Running.stop()`, and `App.deinit()`. `destroyWindow()` reclaims one window while others run; `requestExit()` closes every page and ends the active wait from any thread. | | `webui_set_context()`, `webui_get_context()` | Binding and event-handler `user_data`. | | `webui_bind()` | `Window.bind(io, name, handler, user_data)` supports explicit calls, DOM clicks, and runtime replacement. `Window.onEvent(io, handler, user_data)` updates event handling. `CMD_ADD_ID` pushes new registrations and authentication replays current state; in-flight work keeps its handler snapshot. | | `webui_get_count()`, `webui_get_size()`, `webui_get_size_at()` | `Call.arguments.len` and `Call.bytes(index).len`. | diff --git a/src/app.zig b/src/app.zig index 327dc00..30d81ee 100644 --- a/src/app.zig +++ b/src/app.zig @@ -712,7 +712,13 @@ const WindowState = struct { /// Whether registry updates should notify active browser peers. running: std.atomic.Value(bool) = .init(false), - /// This window's directory monitor, cancelled on stop. + /// Owners: the App window list, each WebSocket upgrade record, each + /// in-flight HTTP request, and each deferred reply. The last release + /// frees the window. + references: std.atomic.Value(usize) = .init(1), + /// Set once a destroyed window stops accepting events; never cleared. + retired: std.atomic.Value(bool) = .init(false), + /// This window's directory monitor, cancelled on destroy or stop. monitor_task: std.Io.Group = .init, /// Set by backend `close` calls so `Running.wait()` skips the /// reconnect grace period. Cleared when a new client authenticates, so a @@ -751,6 +757,17 @@ const WindowState = struct { registered: ?EventBinding = null, }; + fn retain(self: *WindowState) void { + const previous = self.references.fetchAdd(1, .monotonic); + std.debug.assert(previous > 0); + } + + fn release(self: *WindowState) void { + if (self.references.fetchSub(1, .release) != 1) return; + _ = self.references.load(.acquire); + self.deinit(); + } + fn deinit(self: *WindowState) void { std.debug.assert(self.pending_evals.items.len == 0); std.debug.assert(self.pending_replies == 0); @@ -1254,19 +1271,24 @@ const WindowState = struct { if (self.pending_replies >= self.max_pending_replies) return error.TooManyPendingReplies; self.pending_replies += 1; + self.retain(); return self.clients.items[index].peer.clone(); } fn releaseReply(self: *WindowState, io: std.Io) void { - self.mutex.lockUncancelable(io); - defer self.mutex.unlock(io); - std.debug.assert(self.pending_replies > 0); - self.pending_replies -= 1; + { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + std.debug.assert(self.pending_replies > 0); + self.pending_replies -= 1; + } + self.release(); } fn reserveEvent(self: *WindowState, io: std.Io) !void { self.mutex.lockUncancelable(io); defer self.mutex.unlock(io); + if (self.retired.load(.acquire)) return error.WindowDestroyed; if (self.pending_events >= self.max_pending_events) return error.TooManyPendingEvents; self.pending_events += 1; @@ -1445,6 +1467,26 @@ const WindowState = struct { } fn cancelEvents(self: *WindowState, io: std.Io) void { + std.debug.assert(self.cancelEventsOnce(io) == 0); + } + + /// Stop accepting events, then cancel and drain until none remain. A + /// dispatch that reserved before `retired` was set may still schedule, + /// so a single cancel is not enough. + fn retireEvents(self: *WindowState, io: std.Io) void { + self.markRetired(io); + while (self.cancelEventsOnce(io) != 0) + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + + /// Refuse new events. Under `mutex` so it orders with `reserveEvent`. + fn markRetired(self: *WindowState, io: std.Io) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + self.retired.store(true, .release); + } + + fn cancelEventsOnce(self: *WindowState, io: std.Io) usize { self.event_tasks.cancel(io); self.event_mutex.lockUncancelable(io); defer self.event_mutex.unlock(io); @@ -1456,7 +1498,7 @@ const WindowState = struct { self.serial_draining = false; self.mutex.lockUncancelable(io); defer self.mutex.unlock(io); - std.debug.assert(self.pending_events == 0); + return self.pending_events; } fn finishEval( @@ -1885,6 +1927,11 @@ pub const Client = struct { state: *WindowState, client_id: u64, + /// The window this client belongs to, like upstream `event->window`. + pub fn window(self: Client) Window { + return .{ .state = self.state }; + } + fn retainPeer(self: Client, io: std.Io) !Linsang.WebSocketPeer { self.state.mutex.lockUncancelable(io); defer self.state.mutex.unlock(io); @@ -2043,6 +2090,47 @@ fn evalBroadcastClient( }; } +fn indexOfWindow(windows: []const *WindowState, state: *WindowState) ?usize { + // ponytail: window counts are tiny; use a map if hundreds become normal. + for (windows, 0..) |window, index| + if (window == state) return index; + return null; +} + +/// Give a window fresh secrets with a capability unique among `existing`. +fn assignWindowIdentity( + io: std.Io, + window: *WindowState, + existing: []const *WindowState, +) !void { + while (true) { + var random: [36]u8 = undefined; + try io.randomSecure(&random); + window.token = std.mem.readInt(u32, random[0..4], .little); + if (window.token == 0) continue; + window.capability = std.fmt.bytesToHex(random[4..20], .lower); + window.cookie = std.fmt.bytesToHex(random[20..], .lower); + for (existing) |other| { + if (other != window and + std.mem.eql(u8, &window.capability, &other.capability)) break; + } else return; + } +} + +/// Finish destroying a window that `App.destroyWindow` unregistered. +fn reapWindow(app: *App, state: *WindowState, io: std.Io) std.Io.Cancelable!void { + state.retireEvents(io); + state.monitor_task.cancel(io); + app.removeBrowser(io, state); + { + app.windows_lock.lockUncancelable(io); + defer app.windows_lock.unlock(io); + const index = indexOfWindow(app.retiring.items, state).?; + _ = app.retiring.swapRemove(index); + } + state.release(); +} + fn monitorDirectory( state: *WindowState, io: std.Io, @@ -2506,6 +2594,14 @@ pub const App = struct { gpa: std.mem.Allocator, options: Options, windows: std.ArrayList(*WindowState) = .empty, + /// Guards `windows`, `retiring` and `accepting_windows` while running. + /// Lock order: windows_lock, then any WindowState lock. + windows_lock: std.Io.RwLock = .init, + /// Whether createWindow and destroyWindow may change a running app. + accepting_windows: bool = false, + /// Destroyed windows whose cleanup has not finished yet. + retiring: std.ArrayList(*WindowState) = .empty, + reaper_tasks: std.Io.Group = .init, server: ?Linsang.Server = null, server_io: ?std.Io = null, tls_auth: ?Linsang.tls.CertKeyPair = null, @@ -2550,6 +2646,8 @@ pub const App = struct { .window = window, .cookies = owned, }); + // The record owns a reference until onClose releases it. + window.retain(); _ = self.unauthenticated_connections.fetchAdd(1, .acq_rel); } @@ -2583,7 +2681,8 @@ pub const App = struct { } } - /// Remove one upgrade record; the caller frees its `cookies`. + /// Remove one upgrade record; the caller frees its `cookies` and + /// releases its `window`. fn removeUpgrade(self: *App, io: std.Io, key: usize) ?Upgrade { self.upgrade_mutex.lockUncancelable(io); defer self.upgrade_mutex.unlock(io); @@ -2664,17 +2763,26 @@ pub const App = struct { std.debug.assert(!self.started); std.debug.assert(self.server_io == null); std.debug.assert(self.tls_auth == null); + std.debug.assert(self.reaper_tasks.token.load(.acquire) == null); + std.debug.assert(self.retiring.items.len == 0); + self.retiring.deinit(self.gpa); std.debug.assert(self.managed_browsers.items.len == 0); self.managed_browsers.deinit(self.gpa); std.debug.assert(self.upgrades.items.len == 0); self.upgrades.deinit(self.gpa); - for (self.windows.items) |window| window.deinit(); + for (self.windows.items) |window| { + std.debug.assert(window.references.load(.acquire) == 1); + window.release(); + } self.windows.deinit(self.gpa); self.* = undefined; } + /// Create a window. Before `start` it is served once the app starts. + /// While running it is served at once with fresh credentials, matching + /// upstream where windows may be created at any time; this is safe from + /// handlers and other threads. pub fn createWindow(self: *App, options: WindowOptions) !Window { - if (self.started) return error.AlreadyStarted; try self.options.limits.validate(); var browser_controls: browser.WindowControls = .{ .kiosk = options.kiosk, @@ -2742,10 +2850,79 @@ pub const App = struct { .center = options.center, .browser_controls = browser_controls, }; - try self.windows.append(self.gpa, state); + if (self.started) + try self.registerRunningWindow(state) + else + try self.windows.append(self.gpa, state); return .{ .state = state }; } + /// Serve a window created after `start`. On failure the window is left + /// exactly as `createWindow` built it, for its error cleanup. + fn registerRunningWindow(self: *App, state: *WindowState) !void { + const io = self.server_io.?; + self.windows_lock.lockUncancelable(io); + defer self.windows_lock.unlock(io); + if (!self.accepting_windows) return error.NotRunning; + try self.windows.ensureUnusedCapacity(self.gpa, 1); + try state.content.openDirectory(io); + errdefer state.content.closeDirectory(); + try assignWindowIdentity(io, state, self.windows.items); + state.beginServing(io, self.options.startup_timeout); + state.running.store(true, .release); + errdefer state.running.store(false, .release); + if (self.options.folder_monitor_interval) |interval| + try state.monitor_task.concurrent(io, monitorDirectory, .{ + state, + io, + interval, + }); + self.windows.appendAssumeCapacity(state); + } + + /// Destroy a window, like upstream `webui_destroy`. Before `start` the + /// window is freed at once. While running, routing stops immediately: + /// new requests and connections are refused, connected pages receive a + /// backend close and later messages close their connection. Queued + /// handlers are cancelled and the window's directory monitor and managed + /// browser stop in the background; its memory is freed after its last + /// connection and deferred reply finish, and `Running.stop` completes + /// any cleanup still pending. Safe from handlers, including this + /// window's own. The handle and its clients are invalid afterwards. + pub fn destroyWindow(self: *App, window: Window) !void { + const state = window.state; + if (!self.started) { + const index = indexOfWindow(self.windows.items, state) orelse + return error.UnknownWindow; + _ = self.windows.orderedRemove(index); + state.release(); + return; + } + const io = self.server_io.?; + { + self.windows_lock.lockUncancelable(io); + defer self.windows_lock.unlock(io); + if (!self.accepting_windows) return error.NotRunning; + const index = indexOfWindow(self.windows.items, state) orelse + return error.UnknownWindow; + try self.retiring.ensureUnusedCapacity(self.gpa, 1); + // Keep the state alive for the close notification below; the + // reaper may otherwise free it first. + state.retain(); + _ = self.windows.orderedRemove(index); + self.retiring.appendAssumeCapacity(state); + state.running.store(false, .release); + state.markRetired(io); + // Spawned under the lock: stop() closes registration with this + // lock before it awaits the group, so no spawn races that await. + self.reaper_tasks.concurrent(io, reapWindow, .{ self, state, io }) catch |err| + state.log(.warn, "Window cleanup deferred until stop: {}", .{err}); + } + defer state.release(); + _ = state.broadcast(io, .close, "") catch |err| + state.log(.warn, "Destroy close notification failed: {}", .{err}); + } + pub fn start(self: *App, io: std.Io) !Running { if (self.started) return error.AlreadyStarted; if (self.windows.items.len == 0) return error.NoWindow; @@ -2773,23 +2950,8 @@ pub const App = struct { ); } errdefer self.deinitTls(); - for (self.windows.items, 0..) |window, index| { - while (true) { - var random: [36]u8 = undefined; - try io.randomSecure(&random); - window.token = std.mem.readInt(u32, random[0..4], .little); - if (window.token == 0) continue; - window.capability = std.fmt.bytesToHex(random[4..20], .lower); - window.cookie = std.fmt.bytesToHex(random[20..], .lower); - for (self.windows.items[0..index]) |existing| { - if (std.mem.eql( - u8, - &window.capability, - &existing.capability, - )) break; - } else break; - } - } + for (self.windows.items, 0..) |window, index| + try assignWindowIdentity(io, window, self.windows.items[0..index]); self.exit_requested.store(false, .release); self.unauthenticated_connections.store(0, .release); for (self.windows.items) |window| @@ -2822,8 +2984,13 @@ pub const App = struct { for (self.windows.items) |window| window.running.store(true, .release); errdefer for (self.windows.items) |window| window.running.store(false, .release); - const inner = try self.server.?.start(io); + // Published before the server spawns connection tasks, which may + // create or destroy windows from handlers. self.started = true; + errdefer self.started = false; + self.accepting_windows = true; + errdefer self.accepting_windows = false; + const inner = try self.server.?.start(io); return .{ .app = self, .inner = inner }; } @@ -2949,6 +3116,19 @@ pub const App = struct { return error.NoManagedBrowser; } + /// Stop and forget one window's managed browser, keeping its profile. + fn removeBrowser(self: *App, io: std.Io, window: *WindowState) void { + self.browser_mutex.lockUncancelable(io); + defer self.browser_mutex.unlock(io); + for (self.managed_browsers.items, 0..) |*managed, index| { + if (managed.window != window) continue; + if (managed.child) |*child| child.kill(io); + if (managed.profile) |path| self.gpa.free(path); + _ = self.managed_browsers.swapRemove(index); + return; + } + } + fn stopBrowsers(self: *App, io: std.Io) void { self.browser_mutex.lockUncancelable(io); defer self.browser_mutex.unlock(io); @@ -2959,36 +3139,60 @@ pub const App = struct { self.managed_browsers.clearRetainingCapacity(); } - fn hasWindow(self: *const App, state: *WindowState) bool { - // ponytail: window counts are tiny; use a map if hundreds become normal. - for (self.windows.items) |window| - if (window == state) return true; - return false; + /// Whether `state` is a live window. Compares addresses only, so it + /// never reads a destroyed window. + fn hasWindow(self: *App, state: *WindowState) bool { + const io = self.server_io orelse + return indexOfWindow(self.windows.items, state) != null; + self.windows_lock.lockSharedUncancelable(io); + defer self.windows_lock.unlockShared(io); + return indexOfWindow(self.windows.items, state) != null; } - fn windowByCapability( - self: *const App, + /// The live window for `capability`, retained for the caller. + fn retainWindowByCapability( + self: *App, + io: std.Io, capability: []const u8, ) ?*WindowState { + self.windows_lock.lockSharedUncancelable(io); + defer self.windows_lock.unlockShared(io); for (self.windows.items) |window| - if (std.mem.eql(u8, &window.capability, capability)) return window; - return null; - } - - fn windowForConnection( - self: *const App, - connection: *Linsang.Connection, - ) ?*WindowState { - for (self.windows.items) |window| - if (window.hasConnection(connection)) return window; + if (std.mem.eql(u8, &window.capability, capability)) { + window.retain(); + return window; + }; return null; } - fn hasClients(self: *const App, io: std.Io) bool { + fn hasClients(self: *App, io: std.Io) bool { + self.windows_lock.lockSharedUncancelable(io); + defer self.windows_lock.unlockShared(io); for (self.windows.items) |window| if (window.hasClients(io)) return true; return false; } + + /// Refuse further createWindow and destroyWindow calls, then finish + /// every pending destroy. Afterwards `windows` no longer changes. + fn closeRegistration(self: *App, io: std.Io) void { + { + self.windows_lock.lockUncancelable(io); + defer self.windows_lock.unlock(io); + self.accepting_windows = false; + } + // Group.await still waits for completion when cancelled. + self.reaper_tasks.await(io) catch {}; + while (true) { + const next = blk: { + self.windows_lock.lockUncancelable(io); + defer self.windows_lock.unlock(io); + if (self.retiring.items.len == 0) break :blk null; + break :blk self.retiring.items[0]; + } orelse break; + reapWindow(self, next, io) catch {}; + } + } }; pub const Running = struct { @@ -2999,6 +3203,7 @@ pub const Running = struct { pub fn stop(self: *Running) !void { if (self.stopped) return; try self.inner.stop(); + self.app.closeRegistration(self.inner.io); for (self.app.windows.items) |window| { window.running.store(false, .release); window.monitor_task.cancel(self.inner.io); @@ -3022,10 +3227,16 @@ pub const Running = struct { /// call from any thread or handler; it never blocks on `wait()` itself. pub fn requestExit(self: *const Running) void { if (self.stopped) return; - for (self.app.windows.items) |window| - _ = window.broadcast(self.inner.io, .close, "") catch |err| - window.log(.warn, "Exit close notification failed: {}", .{err}); - self.app.exit_requested.store(true, .release); + const app = self.app; + const io = self.inner.io; + { + app.windows_lock.lockSharedUncancelable(io); + defer app.windows_lock.unlockShared(io); + for (app.windows.items) |window| + _ = window.broadcast(io, .close, "") catch |err| + window.log(.warn, "Exit close notification failed: {}", .{err}); + } + app.exit_requested.store(true, .release); } /// Block until every window is finished, then stop the application. @@ -3049,17 +3260,17 @@ pub const Running = struct { // below 10 ms becomes meaningful. while (!app.exit_requested.load(.acquire)) { const now = std.Io.Clock.Timestamp.now(io, .awake); - var any_connected = false; - for (app.windows.items) |window| { - if (window.ever_connected.load(.acquire)) any_connected = true; - } - var active = false; - for (app.windows.items) |window| { - if (window.keepsWaiting(io, now, any_connected)) { - active = true; - break; + const active = blk: { + app.windows_lock.lockSharedUncancelable(io); + defer app.windows_lock.unlockShared(io); + var any_connected = false; + for (app.windows.items) |window| { + if (window.ever_connected.load(.acquire)) any_connected = true; } - } + for (app.windows.items) |window| + if (window.keepsWaiting(io, now, any_connected)) break :blk true; + break :blk false; + }; if (!active) break; try std.Io.sleep(io, .fromMilliseconds(10), .awake); } @@ -3418,7 +3629,8 @@ fn respondScript( try response.write(body); } -fn route(app: *const App, path: []const u8) ?Route { +/// Resolve a capability path. The caller releases the returned window. +fn route(app: *App, io: std.Io, path: []const u8) ?Route { if (path.len < capability_len + 2 or path[0] != '/' or path[capability_len + 1] != '/') @@ -3426,7 +3638,7 @@ fn route(app: *const App, path: []const u8) ?Route { return null; } return .{ - .window = app.windowByCapability(path[1 .. capability_len + 1]) orelse + .window = app.retainWindowByCapability(io, path[1 .. capability_len + 1]) orelse return null, .resource = path[capability_len + 2 ..], }; @@ -3452,7 +3664,8 @@ fn onRequest( user_data: ?*anyopaque, ) Linsang.Action { const app = appFrom(user_data); - var resolved = route(app, request.path) orelse { + const io = app.server_io orelse return failResponse(response); + var resolved = route(app, io, request.path) orelse { // Browsers request /favicon.ico at the origin root for pages that do // not declare an icon. The default icon is public, constant data. if (std.mem.eql(u8, request.path, "/favicon.ico") or @@ -3461,6 +3674,8 @@ fn onRequest( response.status = .not_found; return .respond; }; + // Released last: every use of the window below happens before it. + defer resolved.window.release(); const path_storage = app.gpa.alloc(u8, resolved.resource.len) catch return failResponse(response); var owns_path = true; @@ -3470,7 +3685,6 @@ fn onRequest( return .respond; }; const window = resolved.window; - const io = app.server_io orelse return failResponse(response); window.recordRequest(io); window.content_mutex.lockSharedUncancelable(io); defer window.content_mutex.unlockShared(io); @@ -3774,10 +3988,11 @@ fn onOpen(connection: *Linsang.Connection, user_data: ?*anyopaque) void { // Linsang guarantees req remains valid through this callback. Copy only // stable identity and a bounded cookie copy: no request/header/path slice // survives the callback. - const resolved = route(app, connection.req.path) orelse { + const resolved = route(app, connection.io, connection.req.path) orelse { connection.wsClose(.policy_violation, ""); return; }; + defer resolved.window.release(); const cookies = connection.req.header("cookie") orelse ""; app.admitUpgrade(connection.io, @intFromPtr(connection), resolved.window, cookies) catch |err| { connection.wsClose(switch (err) { @@ -3794,7 +4009,17 @@ fn onMessage( user_data: ?*anyopaque, ) void { const app = appFrom(user_data); - const authenticated = app.windowForConnection(connection); + // The upgrade record keeps its window alive until this connection's + // onClose, and callbacks of one connection never overlap. + const upgraded = app.authorizedWindow(connection.io, @intFromPtr(connection)); + if (upgraded) |window| if (window.retired.load(.acquire)) { + connection.wsClose(.going_away, ""); + return; + }; + const authenticated: ?*WindowState = if (upgraded) |window| + if (window.hasConnection(connection)) window else null + else + null; if (message.opcode == .text and std.mem.eql(u8, message.data, "ping")) { if (authenticated == null) { connection.wsClose(.policy_violation, ""); @@ -3829,7 +4054,7 @@ fn onMessage( }; if (packet.header.command == .check_token) { - const window = app.authorizedWindow(connection.io, @intFromPtr(connection)) orelse { + const window = upgraded orelse { connection.wsClose(.policy_violation, ""); return; }; @@ -3973,14 +4198,15 @@ fn onMessage( fn onClose(connection: *Linsang.Connection, user_data: ?*anyopaque) void { const app = appFrom(user_data); const upgrade = app.removeUpgrade(connection.io, @intFromPtr(connection)) orelse return; - defer app.gpa.free(upgrade.cookies); const window = upgrade.window; + defer window.release(); + defer app.gpa.free(upgrade.cookies); if (window.disconnected(connection)) |client| { window.dispatchEvent(connection.io, .{ .kind = .disconnected, .client = client, .cookies = upgrade.cookies, - }) catch |err| + }) catch |err| if (err != error.WindowDestroyed) window.log(.err, "WebUI event dispatch failed: {}", .{err}); } } @@ -4795,13 +5021,16 @@ test "call accessors, window creation, and routes" { ); const resolved = route( &app, + std.testing.io, "/0123456789abcdef0123456789abcdef/webui.js", ).?; + defer resolved.window.release(); try std.testing.expect(resolved.window == window.state); try std.testing.expectEqualStrings("webui.js", resolved.resource); - try std.testing.expect(route(&app, "/short/") == null); + try std.testing.expect(route(&app, std.testing.io, "/short/") == null); try std.testing.expect(route( &app, + std.testing.io, "/ffffffffffffffffffffffffffffffff/", ) == null); @@ -7688,6 +7917,144 @@ test "JavaScript and Zig calls complete over HTTP and WebSocket" { try std.testing.expect(secondary_events.disconnected.load(.acquire)); } +test "destroying a window before start frees it at once" { + var app = App.init(std.testing.allocator, .{}); + defer app.deinit(); + const first = try app.createWindow(.{ .content = .{ .html = "first" } }); + const second = try app.createWindow(.{ .content = .{ .html = "second" } }); + try app.destroyWindow(first); + // Membership compares addresses only; the freed state is never read. + try std.testing.expectError(error.UnknownWindow, app.destroyWindow(first)); + try std.testing.expectEqual(@as(usize, 1), app.windows.items.len); + try std.testing.expect(app.windows.items[0] == second.state); + try app.destroyWindow(second); + try std.testing.expectError(error.NoWindow, app.start(std.testing.io)); +} + +const RuntimeDestroyCapture = struct { + app: *App, + destroyed: std.atomic.Value(bool) = .init(false), + + fn leave(call: *Call, user_data: ?*anyopaque) !void { + const self: *RuntimeDestroyCapture = @ptrCast(@alignCast(user_data.?)); + // Destroying the handler's own window must not wait for itself. + try self.app.destroyWindow(call.client.window()); + self.destroyed.store(true, .release); + try call.reply("bye"); + } +}; + +test "windows are created and destroyed while the app runs" { + try requireSocketIntegration(); + const gpa = std.testing.allocator; + var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited }); + defer threaded.deinit(); + const io = threaded.io(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(io, .{ .sub_path = "index.html", .data = "runtime folder" }); + const folder = try std.fmt.allocPrint(gpa, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + defer gpa.free(folder); + var app = App.init(gpa, .{ + .startup_timeout = null, + .folder_monitor_interval = .fromMilliseconds(5), + }); + defer app.deinit(); + const primary = try app.createWindow(.{ .content = .{ .html = "primary page" } }); + var running = try app.start(io); + defer running.stop() catch {}; + + // A window created while running is served at once with fresh secrets. + const runtime = try app.createWindow(.{ .content = .{ .html = "runtime page" } }); + try std.testing.expect(runtime.state.token != 0); + try std.testing.expect(!std.mem.eql(u8, &runtime.state.capability, &primary.state.capability)); + try std.testing.expect(app.hasWindow(runtime.state)); + var runtime_target: [64]u8 = undefined; + const runtime_path = try std.fmt.bufPrint(&runtime_target, "/{s}/", .{runtime.state.capability}); + var primary_target: [64]u8 = undefined; + const primary_path = try std.fmt.bufPrint(&primary_target, "/{s}/", .{primary.state.capability}); + var response: [2048]u8 = undefined; + var bytes = try getTestPath(running.inner.address, io, runtime_path, "runtime page", &response); + try std.testing.expect(std.mem.startsWith(u8, bytes, "HTTP/1.1 200")); + + var capture: RuntimeDestroyCapture = .{ .app = &app }; + try runtime.bind(io, "leave", RuntimeDestroyCapture.leave, &capture); + const stream = try connectTestWebSocket(running.inner.address, io, &runtime.state.capability); + defer stream.close(io); + var frame: [256]u8 = undefined; + try std.testing.expect(try authenticateTestClient( + stream, + io, + gpa, + runtime.state.token, + &runtime.state.capability, + &frame, + )); + try waitForShown(runtime, io); + const token = runtime.state.token; + var packet: std.ArrayList(u8) = .empty; + defer packet.deinit(gpa); + try protocol.append(&packet, gpa, .{ .token = token, .id = 7, .command = .call }, "leave\x00\x00"); + try sendClientFrame(stream, io, packet.items); + // The page is told to close. Destroy cancels the window's running + // handlers, the caller included, so its reply is best effort. + const closed = try protocol.decode(try readServerFrame(stream, io, &frame)); + try std.testing.expectEqual(protocol.Command.close, closed.header.command); + try waitForFlag(io, &capture.destroyed); + try std.testing.expect(!app.hasWindow(runtime.state)); + try std.testing.expectError(error.UnknownWindow, app.destroyWindow(runtime)); + bytes = try getTestPath(running.inner.address, io, runtime_path, "\r\n\r\n", &response); + try std.testing.expect(std.mem.startsWith(u8, bytes, "HTTP/1.1 404")); + try disconnectTestStream(stream, io); + + // Destroyed from outside a handler: later messages close the transport. + const connected = try app.createWindow(.{ .content = .{ .html = "connected page" } }); + const second_stream = try connectTestWebSocket(running.inner.address, io, &connected.state.capability); + defer second_stream.close(io); + try std.testing.expect(try authenticateTestClient( + second_stream, + io, + gpa, + connected.state.token, + &connected.state.capability, + &frame, + )); + try waitForShown(connected, io); + const connected_token = connected.state.token; + try app.destroyWindow(connected); + const notified = try protocol.decode(try readServerFrame(second_stream, io, &frame)); + try std.testing.expectEqual(protocol.Command.close, notified.header.command); + packet.clearRetainingCapacity(); + try protocol.append(&packet, gpa, .{ .token = connected_token, .command = .click }, "late\x00"); + try sendClientFrame(second_stream, io, packet.items); + _ = try readServerFrameOpcode(second_stream, io, .close, &frame); + try disconnectTestStream(second_stream, io); + + // Other windows keep running. + bytes = try getTestPath(running.inner.address, io, primary_path, "primary page", &response); + try std.testing.expect(std.mem.startsWith(u8, bytes, "HTTP/1.1 200")); + + // Runtime directory windows get their own monitor, stopped on destroy. + const folder_window = try app.createWindow(.{ .content = .{ .directory = folder } }); + try std.testing.expect(folder_window.state.monitor_task.token.load(.acquire) != null); + var folder_target: [80]u8 = undefined; + const folder_path = try std.fmt.bufPrint(&folder_target, "/{s}/index.html", .{folder_window.state.capability}); + bytes = try getTestPath(running.inner.address, io, folder_path, "runtime folder", &response); + try std.testing.expect(std.mem.startsWith(u8, bytes, "HTTP/1.1 200")); + try app.destroyWindow(folder_window); + bytes = try getTestPath(running.inner.address, io, folder_path, "\r\n\r\n", &response); + try std.testing.expect(std.mem.startsWith(u8, bytes, "HTTP/1.1 404")); + + // With every window destroyed, wait() returns and stops the app. + try app.destroyWindow(primary); + try running.wait(); + try std.testing.expect(!app.started); + try std.testing.expectEqual(@as(usize, 0), app.windows.items.len); + // After stop, windows are created for the next start again. + const next = try app.createWindow(.{ .content = .{ .html = "next" } }); + try std.testing.expect(app.windows.items[0] == next.state); +} + test "multi-client limits, targeting, and disconnect lifecycle" { try requireSocketIntegration(); const gpa = std.testing.allocator; @@ -8401,10 +8768,13 @@ test "upgrade admission owns only accepted connections and removes every state" fn take(owner: *App, key: usize) ?*WindowState { const upgrade = owner.removeUpgrade(std.testing.io, key) orelse return null; owner.gpa.free(upgrade.cookies); + // The app list still owns the window after the record's release. + upgrade.window.release(); return upgrade.window; } }.take; try app.admitUpgrade(io, 1, first.state, "session=one; theme=dark"); + try std.testing.expectEqual(@as(usize, 2), first.state.references.load(.acquire)); try std.testing.expectError(error.ClientLimitReached, app.admitUpgrade(io, 2, second.state, "")); try std.testing.expect(removedWindow(&app, 2) == null); try std.testing.expect(app.authorizedWindow(io, 1) == first.state); @@ -8546,7 +8916,10 @@ fn upgradeAllocationFailures(gpa: std.mem.Allocator) !void { defer app.deinit(); const window = try app.createWindow(.{ .content = .{ .html = "admission allocation" } }); try app.admitUpgrade(std.testing.io, 1, window.state, "session=allocation"); - defer if (app.removeUpgrade(std.testing.io, 1)) |upgrade| gpa.free(upgrade.cookies); + defer if (app.removeUpgrade(std.testing.io, 1)) |upgrade| { + gpa.free(upgrade.cookies); + upgrade.window.release(); + }; } test "upgrade allocation failures do not retain admission ownership" { From 02175641fc076ca98ddc1b5b62e88bba82227bcb Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 21:34:31 +0800 Subject: [PATCH 13/36] fix(app): notify a destroyed window's pages before cancelling its handlers destroyWindow spawned the reaper before sending the close notification. When called from one of the window's own handlers, the reaper could cancel that handler mid-broadcast, the write failed with error.Canceled, and the transport was closed without the notification. The reaper now starts after the broadcast, still under the registry lock so it cannot race stop(). --- src/app.zig | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app.zig b/src/app.zig index 30d81ee..f184853 100644 --- a/src/app.zig +++ b/src/app.zig @@ -2906,21 +2906,27 @@ pub const App = struct { const index = indexOfWindow(self.windows.items, state) orelse return error.UnknownWindow; try self.retiring.ensureUnusedCapacity(self.gpa, 1); - // Keep the state alive for the close notification below; the - // reaper may otherwise free it first. + // Keep the state alive until this call returns; once it is in + // `retiring`, stop() may reap it concurrently. state.retain(); _ = self.windows.orderedRemove(index); self.retiring.appendAssumeCapacity(state); state.running.store(false, .release); state.markRetired(io); - // Spawned under the lock: stop() closes registration with this - // lock before it awaits the group, so no spawn races that await. - self.reaper_tasks.concurrent(io, reapWindow, .{ self, state, io }) catch |err| - state.log(.warn, "Window cleanup deferred until stop: {}", .{err}); } defer state.release(); + // Notify before the reaper starts: it cancels this window's handlers, + // which may include the caller and would cancel this write. _ = state.broadcast(io, .close, "") catch |err| state.log(.warn, "Destroy close notification failed: {}", .{err}); + self.windows_lock.lockUncancelable(io); + defer self.windows_lock.unlock(io); + // Once registration closed, stop() reaps everything in `retiring`. + // Spawning only under the lock while accepting means no spawn can + // race its await of the group. + if (self.accepting_windows) + self.reaper_tasks.concurrent(io, reapWindow, .{ self, state, io }) catch |err| + state.log(.warn, "Window cleanup deferred until stop: {}", .{err}); } pub fn start(self: *App, io: std.Io) !Running { From 5c0e17864aaf262de3234eacf6c323c6566f3873 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 21:34:31 +0800 Subject: [PATCH 14/36] test(app): bound the authentication deadline ping loop by elapsed time The loop slept 30 times for 100 ms. On a slow runner that exceeded the five-second authentication deadline, so the server closed the socket under the pings and the test failed with a write error. Ping for three seconds of wall time and stop pinging once the socket is closed. --- src/app.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app.zig b/src/app.zig index f184853..e20f937 100644 --- a/src/app.zig +++ b/src/app.zig @@ -8905,8 +8905,11 @@ test "silent and control-pinging upgrades expire and restore admission capacity" defer pinging.close(io); try waitForCount(io, &app.unauthenticated_connections, 2); // Control traffic must not refresh the absolute authentication deadline. - for (0..30) |_| { - try sendClientFrameOpcode(pinging, io, .ping, ""); + // Ping for three seconds of wall time; a slow runner may already reach + // the five-second deadline, which closes the socket under the pings. + const pings_started = std.Io.Clock.Timestamp.now(io, .awake).raw.toMilliseconds(); + while (std.Io.Clock.Timestamp.now(io, .awake).raw.toMilliseconds() - pings_started < 3000) { + sendClientFrameOpcode(pinging, io, .ping, "") catch break; try std.Io.sleep(io, .fromMilliseconds(100), .awake); } try std.Io.sleep(io, .fromMilliseconds(2500), .awake); From 051683ac1b8b2e0bb8592fb43fe31123c170418e Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 21:46:18 +0800 Subject: [PATCH 15/36] feat(browser): write WebUI app-mode settings into Firefox profiles Add upstream's toolbar-hiding userChrome.css and a user.js that enables it and turns off the default-browser check, the close warning, tabs in the title bar, and the first-run pages. user.js is rewritten on every call and always sets browser.display.document_color_use, so a changed high-contrast setting takes effect even after Firefox stored the old value in prefs.js. --- src/browser.zig | 78 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/browser.zig b/src/browser.zig index a25f31b..b573b50 100644 --- a/src/browser.zig +++ b/src/browser.zig @@ -493,6 +493,49 @@ pub fn deleteManagedProfile( return deleteProfilePath(io, path); } +/// Preferences written to every generated Firefox profile, like upstream: +/// userChrome.css support, no default-browser check, close warning, or tabs +/// in the title bar. The first-run pages are also suppressed, matching +/// Chromium's `--no-first-run`. +const firefox_user_js = + \\user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true); + \\user_pref("browser.shell.checkDefaultBrowser", false); + \\user_pref("browser.tabs.warnOnClose", false); + \\user_pref("browser.tabs.inTitlebar", 0); + \\user_pref("browser.startup.homepage_override.mstone", "ignore"); + \\user_pref("startup.homepage_welcome_url", ""); + \\user_pref("startup.homepage_welcome_url.additional", ""); + \\user_pref("browser.aboutwelcome.enabled", false); + \\user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true); + \\ +; +/// Upstream's userChrome.css: hide the toolbars so the page fills the window. +const firefox_user_chrome = + "#navigator-toolbox,#TabsToolbar,#nav-bar,#PersonalToolbar,#sidebar-box{" ++ + "visibility:collapse!important;height:0!important;margin:0!important;padding:0!important;}" ++ + "#titlebar{visibility:visible!important;display:flex!important;}#browser{" ++ + "margin-top:0!important;padding-top:0!important;}"; + +/// Write WebUI's app-mode settings into a generated Firefox profile. Firefox +/// applies `user.js` at every start, so it is rewritten on each launch and a +/// changed high-contrast setting takes effect; 0 restores Firefox's default +/// after an earlier launch stored 1 in prefs.js. +fn prepareFirefoxProfile(io: std.Io, directory: []const u8, high_contrast: bool) !void { + const cwd = std.Io.Dir.cwd(); + try cwd.createDirPath(io, directory); + var profile = try cwd.openDir(io, directory, .{}); + defer profile.close(io); + try profile.writeFile(io, .{ + .sub_path = "user.js", + .data = if (high_contrast) + firefox_user_js ++ "user_pref(\"browser.display.document_color_use\", 0);\n" + else + firefox_user_js ++ "user_pref(\"browser.display.document_color_use\", 1);\n", + }); + try profile.createDirPath(io, "chrome"); + try profile.writeFile(io, .{ .sub_path = "chrome/userChrome.css", .data = firefox_user_chrome }); +} + /// Internal ownership helper; only call with a generated root or retained leaf. pub fn deleteProfilePath(io: std.Io, path: []const u8) !bool { const parent_path = std.fs.path.dirname(path) orelse return false; @@ -873,6 +916,41 @@ test "managed profiles and default arguments cover the chromium family" { try std.testing.expectStringStartsWith(argument, "--"); } +test "generated Firefox profiles receive WebUI app-mode settings" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const directory = try std.fmt.allocPrint( + std.testing.allocator, + ".zig-cache/tmp/{s}/firefox/window", + .{tmp.sub_path}, + ); + defer std.testing.allocator.free(directory); + var buffer: [2048]u8 = undefined; + try prepareFirefoxProfile(io, directory, false); + var user_js = try tmp.dir.readFile(io, "firefox/window/user.js", &buffer); + try std.testing.expect(std.mem.startsWith(u8, user_js, firefox_user_js)); + try std.testing.expect(std.mem.endsWith(u8, user_js, "user_pref(\"browser.display.document_color_use\", 1);\n")); + try std.testing.expectEqualStrings( + firefox_user_chrome, + try tmp.dir.readFile(io, "firefox/window/chrome/userChrome.css", &buffer), + ); + // Relaunching rewrites rather than appends, and restores the default. + try prepareFirefoxProfile(io, directory, true); + user_js = try tmp.dir.readFile(io, "firefox/window/user.js", &buffer); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, user_js, "legacyUserProfileCustomizations")); + try std.testing.expect(std.mem.endsWith(u8, user_js, "user_pref(\"browser.display.document_color_use\", 0);\n")); + try std.testing.expectEqualStrings( + firefox_user_chrome, + try tmp.dir.readFile(io, "firefox/window/chrome/userChrome.css", &buffer), + ); + // A file where the profile should be is reported, not replaced. + try tmp.dir.writeFile(io, .{ .sub_path = "occupied", .data = "" }); + const occupied = try std.fmt.allocPrint(std.testing.allocator, ".zig-cache/tmp/{s}/occupied", .{tmp.sub_path}); + defer std.testing.allocator.free(occupied); + try std.testing.expectError(error.NotDir, prepareFirefoxProfile(io, occupied, true)); +} + test "parent process ID identifies the backend process" { const actual = try parentProcessId(); try std.testing.expect(actual != 0); From 1d7c06b75b8e6c332ea47884e8734011dee65991 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 21:46:18 +0800 Subject: [PATCH 16/36] feat(browser): launch Firefox windows with generated app-mode profiles Firefox windows without a caller profile now get a per-window generated profile like Chromium windows, prepared with the app-mode settings before each launch. high_contrast = false is honoured through the profile; a caller profile is never modified, so it still returns UnsupportedBrowserHighContrast. Snap Firefox cannot see the host /tmp. When /snap/bin/firefox exists, the Firefox root moves into the snap's user directory, found through the passwd home as snapd does, because Zig 0.16 exposes no process environment here. Profile lookups therefore take an Io, and launch receives the generated profile separately from the caller's controls. --- README.md | 44 ++++++++---- docs/PURE_ZIG_REFACTOR.md | 16 +++-- src/app.zig | 119 +++++++++++++++++++++++++++--- src/browser.zig | 148 ++++++++++++++++++++++++++++++++++---- 4 files changed, 284 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 0e2844d..7e0dea8 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ compile or link the upstream WebUI C library or CivetWeb. support. The core rewrite is substantial, but full upstream behavioral parity is not -complete. A fresh source audit found gaps in Firefox app profiles, browser -discovery, and native drag/resize, title and navigation integration. See the +complete. A fresh source audit found gaps in browser discovery and native +drag/resize, title and navigation integration. See the [open semantic gaps](docs/PURE_ZIG_REFACTOR.md#open-semantic-gaps) and the [source comparison](docs/UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). @@ -69,8 +69,9 @@ The current phase provides: - explicit browser launching with custom executable paths and argv; - per-window kiosk and headless modes plus persistent initial and runtime size and position; -- per-window Chromium forced-color control and browser-native high-contrast - detection; +- per-window Chromium and Firefox forced-color control and browser-native + high-contrast detection; +- generated Firefox app profiles that hide the browser toolbars; - per-window browser profile directories with deletable managed profiles, and Chromium-family proxy rules; - per-window browser child identifiers and deterministic process cleanup; @@ -230,8 +231,11 @@ OS-handler fallback inside `Window.open()` cannot honour these controls, and it returns `error.ExplicitBrowserRequired` instead of ignoring them. Set `.high_contrast = false` in `App.WindowOptions` to disable Chromium's -forced-color feature for that window. Firefox and Safari return -`error.UnsupportedBrowserHighContrast` instead of ignoring this setting. +forced-color feature for that window. Firefox has no flag for it, so the +setting is written into the window's generated Firefox profile; with a +caller-managed `.profile_directory`, which zig-webui never modifies, Firefox +returns `error.UnsupportedBrowserHighContrast`. Safari always returns that +error instead of ignoring this setting. The browser-side `webui.isHighContrast()` detects active forced colors or a stronger contrast preference through native media queries and requires no external OS program. @@ -245,16 +249,28 @@ Firefox returns `error.UnsupportedBrowserProxy` for proxy configuration; Safari returns `error.UnsupportedBrowserProfile` or `error.UnsupportedBrowserProxy` instead of silently ignoring either option. -Without `.profile_directory`, each Chromium-family window gets an independent -managed profile leaf under its browser-family temporary root, for example -`/tmp/.WebUI/WebUIChromeProfile/`. Different windows no -longer hand their URL to the same browser process. Reopening a window stops and -reaps its previous child before launching the replacement. A failed replacement -leaves no stale child identifier. +Without `.profile_directory`, each Chromium-family or Firefox window gets an +independent managed profile leaf under its browser-family temporary root, for +example `/tmp/.WebUI/WebUIChromeProfile/`. Different windows +no longer hand their URL to the same browser process. Reopening a window stops +and reaps its previous child before launching the replacement. A failed +replacement leaves no stale child identifier. + +Before each launch, a generated Firefox profile receives upstream's app-mode +setup: a `chrome/userChrome.css` that hides the tab strip, toolbars, and +sidebar, and a `user.js` that enables it and turns off the default-browser +check, the close warning, tabs in the title bar, and the first-run pages. +`user.js` is rewritten each time, so a changed `.high_contrast` applies on the +next launch. Snap Firefox cannot see the host `/tmp`, so when +`/snap/bin/firefox` exists on Linux, Firefox profiles live in the snap's user +directory, `/snap/firefox/common/.mozilla/firefox/.WebUI/WebUIFirefoxProfile`, +like upstream. The home directory comes from the current user's `/etc/passwd` +entry, as snapd does; without one, the launch returns +`error.HomeDirectoryUnavailable`. `Window.deleteProfile(&running)` stops the retained child and removes only that -window's generated leaf. `managedProfileDirectory(gpa, browser)` returns the -family root; `deleteManagedProfile` and `deleteAllManagedProfiles` remove roots +window's generated leaf. `managedProfileDirectory(gpa, io, browser)` returns +the family root; `deleteManagedProfile` and `deleteAllManagedProfiles` remove roots and all their leaves, so stop every associated browser before using them. Caller-provided profiles remain caller-owned; `Window.deleteProfile` returns `error.CallerManagedProfile`. Do not share a caller profile with other live diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index ba63dde..084a0a4 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -49,7 +49,6 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Area | Remaining behavior, not covered by existing API mappings | |---|---| -| Firefox app mode | No generated Firefox app profile/userChrome.css or managed preference setup. Existing caller-profile support and explicit high-contrast error do not implement those capabilities. | | Browser discovery | Registered Windows Chromium using `chrome.exe` and macOS bundles outside the fixed application directories lack upstream discovery paths. | | Native interaction | Missing GTK custom drag/edge resize, Windows draggable-region setup and resizable frameless host behavior, and Cocoa frameless background movement. | | Native page integration | No upstream page-title-to-host synchronization; no GTK engine-level navigation-policy interception independent of a live bridge. | @@ -64,6 +63,7 @@ Closed after the rescan, each with focused tests in the same change: | Default favicon | `favicon.ico`/`favicon.svg` resolve custom icon, then a readable directory file, then upstream's default SVG (`.ico` answers `302` to `favicon.svg`), at both the capability root and the origin root. Test: `favicon falls back from custom icon to local file to the default`. | | Content composition | `Content.site` composes optional embedded HTML, a declinable handler, and a root folder in upstream resolution order; `Window.installContent` replaces resources without navigation. Tests: `site content resolves handler, virtual index, html, folder, and entry in upstream order`, `installed content changes resources without navigating clients`. | | Live window lifecycle | `App.createWindow` serves windows created while running with fresh credentials, folder, and monitor. `App.destroyWindow` unregisters at once (`404` routing, backend close, `1001` on later messages), then cancels the window's handlers, monitor, and managed browser in the background, and frees it after its last connection, request, and deferred reply. This works from the window's own handlers via `Client.window()`, and `Running.stop` finishes pending cleanup. Tests: `windows are created and destroyed while the app runs`, `destroying a window before start frees it at once`, `upgrade admission owns only accepted connections and removes every state`. | +| Firefox app mode | Firefox windows without a caller profile get a generated per-window profile; before each launch it receives upstream's `chrome/userChrome.css` toolbar suppression and a rewritten `user.js` (stylesheet support, no default-browser check, close warning, tabs in title bar, or first-run pages, and the `browser.display.document_color_use` high-contrast override). Snap Firefox profiles live under the snap's user directory from the passwd home. A caller profile is never modified, so it rejects `high_contrast = false`. Tests: `generated Firefox profiles receive WebUI app-mode settings`, `Firefox windows launch with a generated app-mode profile`, `Firefox launches reject profile combinations they cannot honour`, `passwd home lookup accepts only well-formed absolute entries`. | | Entry and custom routing | `Site.entry` redirects the root to a validated relative entry file, and handlers that decline a path (empty `404`) are probed for the entry name or `index.*` with `302` redirects, for both `.site` and `.custom`. Same tests as content composition. | Borrowed custom HTTP handlers can await work before returning through `std.Io`; @@ -424,7 +424,7 @@ Mappings with an explicit remaining gap are partial, not parity-complete: | `webui_focus()` | `Window.focus()` restores and focuses the visible top-level window belonging to the retained browser child on Windows. Missing children, invalid process handles, unavailable windows, and rejected foreground requests return explicit errors; Linux and macOS return `error.UnsupportedPlatform` instead of silently doing nothing. | | `webui_delete_profile()`, `webui_delete_all_profiles()` | `Window.deleteProfile()`, `deleteManagedProfile()`, and `deleteAllManagedProfiles()` remove generated profile directories only. A window configured with `.profile_directory` returns `error.CallerManagedProfile`; caller-owned directories are never deleted. | | `webui_set_size()`, `webui_set_position()` | `App.WindowOptions.size` and `.position` set initial geometry. `Window.setSize()` and `Window.setPosition()` persist updates, notify connected clients, replay the latest geometry to later clients, and affect subsequent explicit browser launches. | -| `webui_set_high_contrast()`, `webui_is_high_contrast()` | `App.WindowOptions.high_contrast` controls Chromium forced-color support with explicit unsupported-browser errors. Browser-side `webui.isHighContrast()` uses native forced-color and contrast media queries without external programs. | +| `webui_set_high_contrast()`, `webui_is_high_contrast()` | `App.WindowOptions.high_contrast` controls Chromium forced-color support and the generated Firefox profile's `browser.display.document_color_use` preference, with explicit errors for Safari and caller-managed Firefox profiles. Browser-side `webui.isHighContrast()` uses native forced-color and contrast media queries without external programs. | | `webui_open_url()` | `openUrl()` safely passes a non-empty URL as one argument to the platform default opener. | | `webui_get_best_browser()`, `webui_browser_exist()` | `bestBrowser()` and `browserExists()` discover registered or executable browser candidates through the public `Browser` enum. | | `webui_show_browser()`, `webui_set_browser_folder()`, `webui_set_custom_parameters()` | `Window.openWithBrowser()` accepts a `BrowserLaunchOptions` value with an explicit browser, optional full executable path, and additional argv. An empty argv applies the Chromium default arguments; a non-empty argv replaces them, matching upstream `custom_parameters`. | @@ -434,7 +434,7 @@ Mappings with an explicit remaining gap are partial, not parity-complete: | `webui_set_runtime()` | `App.WindowOptions.runtime` selects Deno, Node.js, or Bun for `.js`/`.ts`. Physical directories first redirect to the first `index.html`, `index.htm`, `index.ts`, or `index.js`; only a selected script is interpreted. Executables receive argv without a shell. Failures deliberately answer `503`/`504`/`502`, never partial stdout or diagnostics. | | `webui_set_config(folder_monitor)` | `App.Options.folder_monitor_interval` enables portable recursive directory polling and reloads the affected window's connected clients. | | `webui_set_icon()`, `webui_set_icon_file()` | `Window.setIcon()` copies inline data and MIME type; `Window.setIconFile()` loads a supported image file as the window favicon. | -| `webui_set_profile()` | Caller-managed profiles and isolated owned Chromium profile leaves are supported. Partial: Firefox managed app profiles, chrome suppression and preference setup remain absent. | +| `webui_set_profile()` | Caller-managed profiles and isolated owned Chromium and Firefox profile leaves are supported; generated Firefox profiles receive upstream's userChrome.css and app-mode preferences, under the snap user directory for Snap Firefox. | | `webui_set_proxy()` | `App.WindowOptions.proxy_server` is copied and passed as one Chromium-family `--proxy-server` argument. Unsupported browsers return an explicit error. | | `webui_wait()`, `webui_wait_async()` | `Running.wait()` used directly or through `std.Io` concurrency. Each window is evaluated independently: a backend close ends only that window, other disconnects get a 1.5-second grace from the latest disconnect, and a new client clears that window's close intent. Never-connected windows wait for the startup timeout. A second concurrent waiter returns `error.AlreadyWaiting`. | | `webui_close()`, `webui_destroy()`, `webui_exit()`, `webui_clean()` | `Window.close()`, `App.destroyWindow()`, `Running.requestExit()`, `Running.stop()`, and `App.deinit()`. `destroyWindow()` reclaims one window while others run; `requestExit()` closes every page and ends the active wait from any thread. | @@ -508,8 +508,7 @@ Browser discovery, default URL opening, explicit browser selection, custom executable paths and argv, the process-wide backend identifier, per-window direct child identifiers, replacement, and shutdown cleanup are implemented. -Discovery, profile and app-presentation behavior still has the Firefox, -Windows Chromium and macOS resolver gaps listed above. +Discovery still has the Windows Chromium and macOS resolver gaps listed above. ### Browser window controls @@ -519,8 +518,11 @@ Windows Chromium and macOS resolver gaps listed above. - Profile directories and proxy rules are copied into window state and passed as individual browser argv entries. Chromium-family browsers support both; Firefox supports profiles; Safari supports neither. -- Chromium can explicitly disable forced-color support; the browser bridge - detects active high-contrast media preferences. +- Chromium can explicitly disable forced-color support, and generated Firefox + profiles disable it through a preference; the browser bridge detects active + high-contrast media preferences. +- Generated Firefox profiles receive upstream's toolbar-hiding + `userChrome.css` and app-mode `user.js` before every launch. - Windows external-browser focus enumerates visible top-level windows owned by the retained browser child, restores a minimized match, and requests the foreground. Other platforms return `error.UnsupportedPlatform`. diff --git a/src/app.zig b/src/app.zig index e20f937..aac02f7 100644 --- a/src/app.zig +++ b/src/app.zig @@ -3049,19 +3049,19 @@ pub const App = struct { if (executable.len == 0) return error.InvalidBrowserExecutable; self.browser_mutex.lockUncancelable(io); defer self.browser_mutex.unlock(io); - var controls = requested_controls; + const controls = requested_controls; const profile = if (controls.profile_directory == null) - try browser.managedWindowProfileDirectory(self.gpa, options.browser, &window.capability) + try browser.managedWindowProfileDirectory(self.gpa, io, options.browser, &window.capability) else null; var owns_profile = true; defer if (owns_profile) if (profile) |path| self.gpa.free(path); - controls.profile_directory = controls.profile_directory orelse profile; + const effective_profile = controls.profile_directory orelse profile; for (self.managed_browsers.items) |managed| { if (managed.window == window or managed.child == null) continue; const other = managed.profile orelse managed.window.browser_controls.profile_directory; - if (controls.profile_directory != null and other != null and - std.mem.eql(u8, controls.profile_directory.?, other.?)) + if (effective_profile != null and other != null and + std.mem.eql(u8, effective_profile.?, other.?)) return error.BrowserProfileInUse; } const managed = for (self.managed_browsers.items) |*existing| { @@ -3080,7 +3080,7 @@ pub const App = struct { if (managed.profile) |path| self.gpa.free(path); managed.profile = profile; owns_profile = false; - managed.child = try browser.launch(self.gpa, io, url, options, controls); + managed.child = try browser.launch(self.gpa, io, url, options, controls, profile); return managed.child.?.id.?; } @@ -6021,10 +6021,10 @@ test "managed profiles are deletable and caller directories are not" { try std.testing.expectEqual( @as(?[]u8, null), - try browser.managedProfileDirectory(gpa, .firefox), + try browser.managedProfileDirectory(gpa, io, .safari), ); try std.testing.expect( - !try browser.deleteManagedProfile(gpa, io, .firefox), + !try browser.deleteManagedProfile(gpa, io, .safari), ); var app = App.init(gpa, .{}); @@ -6039,7 +6039,7 @@ test "managed profiles are deletable and caller directories are not" { const sibling = try app.createWindow(.{ .content = .{ .html = "independent profile" } }); var running = try app.start(io); defer running.stop() catch {}; - const managed = (try browser.managedWindowProfileDirectory(gpa, .epic, &window.state.capability)).?; + const managed = (try browser.managedWindowProfileDirectory(gpa, io, .epic, &window.state.capability)).?; defer gpa.free(managed); // Nothing launched yet, so no profile can be identified. @@ -6063,7 +6063,7 @@ test "managed profiles are deletable and caller directories are not" { .data = "cached", }); const sibling_id = try sibling.openWithBrowser(&running, .{ .browser = .epic, .executable = executable }); - const sibling_profile = (try browser.managedWindowProfileDirectory(gpa, .epic, &sibling.state.capability)).?; + const sibling_profile = (try browser.managedWindowProfileDirectory(gpa, io, .epic, &sibling.state.capability)).?; defer gpa.free(sibling_profile); defer _ = sibling.deleteProfile(&running) catch false; try std.Io.Dir.cwd().createDirPath(io, sibling_profile); @@ -6095,6 +6095,105 @@ test "managed profiles are deletable and caller directories are not" { ); } +test "Firefox windows launch with a generated app-mode profile" { + if (@import("builtin").os.tag != .linux and @import("builtin").os.tag != .macos) + return error.SkipZigTest; + const gpa = std.testing.allocator; + var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited, .environ = std.testing.environ }); + defer threaded.deinit(); + const io = threaded.io(); + try requireTestRuntime(gpa, io, .node_js); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + // Records what the browser receives at spawn time, then stays alive. + try tmp.dir.writeFile(io, .{ + .sub_path = "fake-firefox", + .data = + \\#!/usr/bin/env node + \\const fs = require('fs'), path = require('path'); + \\const args = process.argv.slice(2); + \\const profile = args[args.indexOf('--profile') + 1]; + \\const report = { + \\ args, + \\ userJs: fs.readFileSync(path.join(profile, 'user.js'), 'utf8'), + \\ userChrome: fs.existsSync(path.join(profile, 'chrome', 'userChrome.css')), + \\}; + \\fs.writeFileSync(path.join(__dirname, 'launch.tmp'), JSON.stringify(report)); + \\fs.renameSync(path.join(__dirname, 'launch.tmp'), path.join(__dirname, 'launch.json')); + \\setInterval(() => {}, 1000); + \\ + , + .flags = .{ .permissions = .executable_file }, + }); + const executable = try std.fmt.allocPrint(gpa, ".zig-cache/tmp/{s}/fake-firefox", .{tmp.sub_path}); + defer gpa.free(executable); + try tmp.dir.createDirPath(io, "caller-profile"); + const caller_profile = try std.fmt.allocPrint(gpa, ".zig-cache/tmp/{s}/caller-profile", .{tmp.sub_path}); + defer gpa.free(caller_profile); + + var app = App.init(gpa, .{}); + defer app.deinit(); + const window = try app.createWindow(.{ + .content = .{ .html = "firefox profile" }, + .high_contrast = false, + .size = .{ .width = 640, .height = 480 }, + }); + const caller_window = try app.createWindow(.{ + .content = .{ .html = "caller firefox profile" }, + .high_contrast = false, + .profile_directory = caller_profile, + }); + var running = try app.start(io); + defer running.stop() catch {}; + const profile = (try browser.managedWindowProfileDirectory(gpa, io, .firefox, &window.state.capability)).?; + defer gpa.free(profile); + + _ = try window.openWithBrowser(&running, .{ .browser = .firefox, .executable = executable }); + defer _ = window.deleteProfile(&running) catch false; + const report_bytes = for (0..1000) |_| { + break tmp.dir.readFileAlloc(io, "launch.json", gpa, .limited(64 << 10)) catch |err| switch (err) { + error.FileNotFound => { + try std.Io.sleep(io, .fromMilliseconds(10), .awake); + continue; + }, + else => return err, + }; + } else return error.Timeout; + defer gpa.free(report_bytes); + const Report = struct { args: []const []const u8, userJs: []const u8, userChrome: bool }; + const report = try std.json.parseFromSlice(Report, gpa, report_bytes, .{}); + defer report.deinit(); + const args = report.value.args; + const page_url = try window.url(&running, gpa); + defer gpa.free(page_url); + try std.testing.expectEqual(@as(usize, 8), args.len); + try std.testing.expectEqualStrings("--profile", args[0]); + try std.testing.expectEqualStrings(profile, args[1]); + try std.testing.expectEqualStrings("-width", args[2]); + try std.testing.expectEqualStrings("640", args[3]); + try std.testing.expectEqualStrings("-height", args[4]); + try std.testing.expectEqualStrings("480", args[5]); + try std.testing.expectEqualStrings("-new-window", args[6]); + try std.testing.expectEqualStrings(page_url, args[7]); + // The profile is ready before the browser starts. + try std.testing.expect(report.value.userChrome); + try std.testing.expect(std.mem.indexOf(u8, report.value.userJs, "legacyUserProfileCustomizations.stylesheets\", true") != null); + try std.testing.expect(std.mem.endsWith(u8, report.value.userJs, "document_color_use\", 1);\n")); + + // The override cannot be applied to a caller profile, which is never + // modified, so the launch is refused before anything is spawned. + try std.testing.expectError( + error.UnsupportedBrowserHighContrast, + caller_window.openWithBrowser(&running, .{ .browser = .firefox, .executable = executable }), + ); + var caller_dir = try tmp.dir.openDir(io, "caller-profile", .{ .iterate = true }); + defer caller_dir.close(io); + var entries = caller_dir.iterate(); + try std.testing.expectEqual(@as(?std.Io.Dir.Entry, null), try entries.next(io)); + + try std.testing.expect(try window.deleteProfile(&running)); + try std.testing.expectError(error.FileNotFound, std.Io.Dir.accessAbsolute(io, profile, .{})); +} test "selected browser launch applies window controls and owns process" { if (@import("builtin").os.tag != .linux and @import("builtin").os.tag != .macos) return error.SkipZigTest; diff --git a/src/browser.zig b/src/browser.zig index b573b50..a7c3bb8 100644 --- a/src/browser.zig +++ b/src/browser.zig @@ -79,7 +79,9 @@ pub const WindowControls = struct { .firefox => { if (self.proxy_server != null) return error.UnsupportedBrowserProxy; - if (!self.high_contrast) + // Firefox has no flag for it: the override is a preference + // that only a WebUI-generated profile may receive. + if (!self.high_contrast and self.profile_directory != null) return error.UnsupportedBrowserHighContrast; if (self.position != null) return error.UnsupportedBrowserControl; @@ -258,17 +260,27 @@ pub fn bestBrowser( return null; } +/// Launch a browser window. `managed_profile` is a WebUI-generated profile +/// used when `controls.profile_directory` is null; Firefox receives WebUI's +/// app-mode settings in it. A caller profile is never modified. pub fn launch( gpa: std.mem.Allocator, io: std.Io, url: []const u8, options: LaunchOptions, controls: WindowControls, + managed_profile: ?[]const u8, ) !std.process.Child { if (url.len == 0) return error.InvalidUrl; if (options.executable) |executable| if (executable.len == 0) return error.InvalidBrowserExecutable; try controls.validateFor(options.browser); + if (managed_profile) |directory| { + if (controls.profile_directory != null) return error.InvalidBrowserProfile; + try (WindowControls{ .profile_directory = directory }).validate(); + } + if (options.browser == .firefox and !controls.high_contrast and managed_profile == null) + return error.UnsupportedBrowserHighContrast; const discovered = if (options.executable == null) try resolveExecutable(gpa, io, options.browser) orelse @@ -285,9 +297,12 @@ pub fn launch( // The owner supplies its retained per-window profile. Never fall back to // the shared family root: Chromium would hand the URL to another process. - if (isChromium(options.browser) and controls.profile_directory == null) + const profile = controls.profile_directory orelse managed_profile; + if (isChromium(options.browser) and profile == null) return error.ManagedBrowserProfileRequired; - const profile = controls.profile_directory; + if (options.browser == .firefox) + if (managed_profile) |directory| + try prepareFirefoxProfile(io, directory, controls.high_contrast); const profile_argument = if (profile) |directory| switch (options.browser) { .firefox, .safari => null, @@ -435,19 +450,26 @@ fn managedProfileName(selected: Browser) ?[]const u8 { .yandex => "WebUIYandexProfile", .opera => "WebUIOperaProfile", .chromium => "WebUIChromiumProfile", - // Firefox profiles live in profiles.ini rather than a directory - // argument, and Safari has no profile support at all. - .firefox, .safari => null, + .firefox => "WebUIFirefoxProfile", + // Safari has no profile support at all. + .safari => null, }; } /// Root of the generated per-window profiles for one browser family. Returns -/// null when no managed profile applies. Caller owns the returned memory. +/// null when no managed profile applies. On Linux with Snap Firefox +/// installed, Firefox profiles live in the snap's own user directory, +/// because a snap cannot see the host `/tmp`; it returns +/// `error.HomeDirectoryUnavailable` when that directory is unknown. +/// Caller owns the returned memory. pub fn managedProfileDirectory( gpa: std.mem.Allocator, + io: std.Io, selected: Browser, ) !?[]u8 { const name = managedProfileName(selected) orelse return null; + if (selected == .firefox) + if (try snapFirefoxProfileRoot(gpa, io)) |root| return root; return switch (builtin.os.tag) { .windows => blk: { const temp = (std.process.Environ{ .block = .global }) @@ -472,10 +494,11 @@ pub fn managedProfileDirectory( /// by App.start, not a caller-supplied filesystem path. pub fn managedWindowProfileDirectory( gpa: std.mem.Allocator, + io: std.Io, selected: Browser, identity: []const u8, ) !?[]u8 { - const root = try managedProfileDirectory(gpa, selected) orelse return null; + const root = try managedProfileDirectory(gpa, io, selected) orelse return null; defer gpa.free(root); return try std.fs.path.join(gpa, &.{ root, identity }); } @@ -488,7 +511,7 @@ pub fn deleteManagedProfile( io: std.Io, selected: Browser, ) !bool { - const path = try managedProfileDirectory(gpa, selected) orelse return false; + const path = try managedProfileDirectory(gpa, io, selected) orelse return false; defer gpa.free(path); return deleteProfilePath(io, path); } @@ -536,6 +559,47 @@ fn prepareFirefoxProfile(io: std.Io, directory: []const u8, high_contrast: bool) try profile.writeFile(io, .{ .sub_path = "chrome/userChrome.css", .data = firefox_user_chrome }); } +/// The managed Firefox root inside Snap Firefox's user directory, or null +/// when Snap Firefox is not installed. Upstream uses the same location. +fn snapFirefoxProfileRoot(gpa: std.mem.Allocator, io: std.Io) !?[]u8 { + if (builtin.os.tag != .linux) return null; + std.Io.Dir.accessAbsolute(io, "/snap/bin/firefox", .{}) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + // snapd derives a snap's user directories from the account's passwd + // home, which is also the only home known without a process environment. + const passwd = std.Io.Dir.cwd().readFileAlloc(io, "/etc/passwd", gpa, .limited(4 << 20)) catch |err| + return switch (err) { + error.OutOfMemory => error.OutOfMemory, + else => error.HomeDirectoryUnavailable, + }; + defer gpa.free(passwd); + const home = passwdHome(passwd, std.os.linux.getuid()) orelse + return error.HomeDirectoryUnavailable; + return try std.fs.path.join(gpa, &.{ home, "snap/firefox/common/.mozilla/firefox/.WebUI/WebUIFirefoxProfile" }); +} + +/// The absolute home directory of `uid` in passwd(5) text, if listed. +fn passwdHome(passwd: []const u8, uid: u32) ?[]const u8 { + var lines = std.mem.splitScalar(u8, passwd, '\n'); + while (lines.next()) |line| { + var fields = std.mem.splitScalar(u8, line, ':'); + _ = fields.next() orelse continue; // name + _ = fields.next() orelse continue; // password + const uid_text = fields.next() orelse continue; + _ = fields.next() orelse continue; // group + _ = fields.next() orelse continue; // comment + const home = fields.next() orelse continue; + if (fields.next() == null) continue; // shell + const entry_uid = std.fmt.parseInt(u32, uid_text, 10) catch continue; + if (entry_uid != uid) continue; + if (home.len < 2 or home[0] != '/') return null; + return home; + } + return null; +} + /// Internal ownership helper; only call with a generated root or retained leaf. pub fn deleteProfilePath(io: std.Io, path: []const u8) !bool { const parent_path = std.fs.path.dirname(path) orelse return false; @@ -834,9 +898,12 @@ test "window controls validate browser support" { (WindowControls{ .proxy_server = "http://127.0.0.1:8080" }) .validateFor(.safari), ); + // Firefox gets the override only through a WebUI-generated profile. + try (WindowControls{ .high_contrast = false }).validateFor(.firefox); try std.testing.expectError( error.UnsupportedBrowserHighContrast, - (WindowControls{ .high_contrast = false }).validateFor(.firefox), + (WindowControls{ .high_contrast = false, .profile_directory = "profiles/firefox" }) + .validateFor(.firefox), ); try std.testing.expectError( error.UnsupportedBrowserHighContrast, @@ -873,13 +940,18 @@ test "window controls validate browser support" { test "managed profiles and default arguments cover the chromium family" { const gpa = std.testing.allocator; for (std.enums.values(Browser)) |selected| { - const directory = try managedProfileDirectory(gpa, selected); + const directory = try managedProfileDirectory(gpa, std.testing.io, selected); defer if (directory) |value| gpa.free(value); switch (selected) { - .firefox, .safari => { + .safari => { try std.testing.expect(!isChromium(selected)); try std.testing.expectEqual(@as(?[]u8, null), directory); }, + .firefox => { + try std.testing.expect(!isChromium(selected)); + // /tmp, or the snap's user directory when Snap Firefox exists. + try std.testing.expectStringEndsWith(directory.?, "WebUIFirefoxProfile"); + }, else => { try std.testing.expect(isChromium(selected)); if (builtin.os.tag != .windows) { @@ -951,6 +1023,58 @@ test "generated Firefox profiles receive WebUI app-mode settings" { try std.testing.expectError(error.NotDir, prepareFirefoxProfile(io, occupied, true)); } +test "passwd home lookup accepts only well-formed absolute entries" { + const passwd = + \\# comment line + \\root:x:0:0:root:/root:/bin/bash + \\short:x:1000 + \\badid:x:10x0:1000::/home/bad:/bin/sh + \\relative:x:1001:1001::home/relative:/bin/sh + \\nohome:x:1002:1002:::/bin/sh + \\jin:x:1000:1000:Jin,,,:/home/jin:/bin/zsh + \\noshell:x:1003:1003::/home/noshell + ; + try std.testing.expectEqualStrings("/root", passwdHome(passwd, 0).?); + try std.testing.expectEqualStrings("/home/jin", passwdHome(passwd, 1000).?); + try std.testing.expectEqual(@as(?[]const u8, null), passwdHome(passwd, 1001)); + try std.testing.expectEqual(@as(?[]const u8, null), passwdHome(passwd, 1002)); + try std.testing.expectEqual(@as(?[]const u8, null), passwdHome(passwd, 1003)); + try std.testing.expectEqual(@as(?[]const u8, null), passwdHome(passwd, 4242)); + try std.testing.expectEqual(@as(?[]const u8, null), passwdHome("", 0)); + try std.testing.expectEqual(@as(?[]const u8, null), passwdHome("root:x:0:0:root:/:/bin/sh", 0)); +} + +test "Firefox launches reject profile combinations they cannot honour" { + const gpa = std.testing.allocator; + const io = std.testing.io; + // No generated profile means no place for the high-contrast override. + try std.testing.expectError(error.UnsupportedBrowserHighContrast, launch( + gpa, + io, + "http://127.0.0.1:1/", + .{ .browser = .firefox, .executable = "missing-firefox" }, + .{ .high_contrast = false }, + null, + )); + // A generated profile never replaces a caller profile. + try std.testing.expectError(error.InvalidBrowserProfile, launch( + gpa, + io, + "http://127.0.0.1:1/", + .{ .browser = .firefox, .executable = "missing-firefox" }, + .{ .profile_directory = "caller" }, + "generated", + )); + try std.testing.expectError(error.InvalidBrowserProfile, launch( + gpa, + io, + "http://127.0.0.1:1/", + .{ .browser = .firefox, .executable = "missing-firefox" }, + .{}, + "", + )); +} + test "parent process ID identifies the backend process" { const actual = try parentProcessId(); try std.testing.expect(actual != 0); From 6227333b1adb5ead3f7b699627d9a19569b0c260 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 21:54:53 +0800 Subject: [PATCH 17/36] feat(browser): tell registered Chromium from Chrome on Windows Chrome and Chromium both install and register chrome.exe. Like upstream, a folder holding Google's initial_preferences or legacy master_preferences file is Google Chrome and any other chrome.exe is Chromium. Every chrome.exe candidate from PATH and App Paths is classified, so a Chromium chrome.exe is no longer reported as Chrome and registered Chromium is discovered. --- README.md | 14 ++++--- docs/PURE_ZIG_REFACTOR.md | 8 ++-- src/browser.zig | 78 +++++++++++++++++++++++++++++++++++---- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7e0dea8..aecc3d2 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ compile or link the upstream WebUI C library or CivetWeb. support. The core rewrite is substantial, but full upstream behavioral parity is not -complete. A fresh source audit found gaps in browser discovery and native -drag/resize, title and navigation integration. See the +complete. A fresh source audit found gaps in macOS browser discovery and +native drag/resize, title and navigation integration. See the [open semantic gaps](docs/PURE_ZIG_REFACTOR.md#open-semantic-gaps) and the [source comparison](docs/UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). @@ -193,9 +193,13 @@ total timeout for connection waiting and JavaScript execution. Call `openUrl(gpa, io, url)` to open any non-empty URL with the OS default handler. `browserExists(gpa, io, browser)` checks an explicit `Browser`, while `bestBrowser(gpa, io)` returns the first installed browser in the preferred -platform order or `null`. Discovery probes Windows application registration, -standard macOS application bundles, and executable candidates on other -platforms without opening the selected browser. +platform order or `null`. Discovery never opens the selected browser. On +Windows it probes `PATH` and the `App Paths` registration. Chrome and Chromium +both install `chrome.exe`, so, like upstream, a folder with Google's +`initial_preferences` or `master_preferences` file is Google Chrome and any +other `chrome.exe` is Chromium. On macOS it checks the standard +`/Applications` and `/System/Applications` bundles. Other platforms run each executable candidate +on `PATH` with `--version`, like upstream. `Window.openWithBrowser(&running, options)` launches a selected `Browser` with an optional full executable path and additional argv. Chromium-family diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index 084a0a4..099fdae 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -49,7 +49,7 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Area | Remaining behavior, not covered by existing API mappings | |---|---| -| Browser discovery | Registered Windows Chromium using `chrome.exe` and macOS bundles outside the fixed application directories lack upstream discovery paths. | +| Browser discovery | macOS bundles outside the fixed application directories lack upstream discovery paths. | | Native interaction | Missing GTK custom drag/edge resize, Windows draggable-region setup and resizable frameless host behavior, and Cocoa frameless background movement. | | Native page integration | No upstream page-title-to-host synchronization; no GTK engine-level navigation-policy interception independent of a live bridge. | | Default presentation | F5/context-menu/DevTools policy differences are intentional UI-policy candidates, not proof of missing protocol support. | @@ -64,6 +64,7 @@ Closed after the rescan, each with focused tests in the same change: | Content composition | `Content.site` composes optional embedded HTML, a declinable handler, and a root folder in upstream resolution order; `Window.installContent` replaces resources without navigation. Tests: `site content resolves handler, virtual index, html, folder, and entry in upstream order`, `installed content changes resources without navigating clients`. | | Live window lifecycle | `App.createWindow` serves windows created while running with fresh credentials, folder, and monitor. `App.destroyWindow` unregisters at once (`404` routing, backend close, `1001` on later messages), then cancels the window's handlers, monitor, and managed browser in the background, and frees it after its last connection, request, and deferred reply. This works from the window's own handlers via `Client.window()`, and `Running.stop` finishes pending cleanup. Tests: `windows are created and destroyed while the app runs`, `destroying a window before start frees it at once`, `upgrade admission owns only accepted connections and removes every state`. | | Firefox app mode | Firefox windows without a caller profile get a generated per-window profile; before each launch it receives upstream's `chrome/userChrome.css` toolbar suppression and a rewritten `user.js` (stylesheet support, no default-browser check, close warning, tabs in title bar, or first-run pages, and the `browser.display.document_color_use` high-contrast override). Snap Firefox profiles live under the snap's user directory from the passwd home. A caller profile is never modified, so it rejects `high_contrast = false`. Tests: `generated Firefox profiles receive WebUI app-mode settings`, `Firefox windows launch with a generated app-mode profile`, `Firefox launches reject profile combinations they cannot honour`, `passwd home lookup accepts only well-formed absolute entries`. | +| Browser discovery | Windows tells Chrome from Chromium among `PATH` and `App Paths` `chrome.exe` candidates by Google's `initial_preferences`/`master_preferences` files, like upstream, so registered Chromium is found and never mistaken for Chrome. Test: `Windows Chrome and Chromium installs are told apart by Google's installer files`. | | Entry and custom routing | `Site.entry` redirects the root to a validated relative entry file, and handlers that decline a path (empty `404`) are probed for the entry name or `index.*` with `302` redirects, for both `.site` and `.custom`. Same tests as content composition. | Borrowed custom HTTP handlers can await work before returning through `std.Io`; @@ -426,7 +427,7 @@ Mappings with an explicit remaining gap are partial, not parity-complete: | `webui_set_size()`, `webui_set_position()` | `App.WindowOptions.size` and `.position` set initial geometry. `Window.setSize()` and `Window.setPosition()` persist updates, notify connected clients, replay the latest geometry to later clients, and affect subsequent explicit browser launches. | | `webui_set_high_contrast()`, `webui_is_high_contrast()` | `App.WindowOptions.high_contrast` controls Chromium forced-color support and the generated Firefox profile's `browser.display.document_color_use` preference, with explicit errors for Safari and caller-managed Firefox profiles. Browser-side `webui.isHighContrast()` uses native forced-color and contrast media queries without external programs. | | `webui_open_url()` | `openUrl()` safely passes a non-empty URL as one argument to the platform default opener. | -| `webui_get_best_browser()`, `webui_browser_exist()` | `bestBrowser()` and `browserExists()` discover registered or executable browser candidates through the public `Browser` enum. | +| `webui_get_best_browser()`, `webui_browser_exist()` | `bestBrowser()` and `browserExists()` discover registered or executable browser candidates through the public `Browser` enum, including registered Windows Chromium. | | `webui_show_browser()`, `webui_set_browser_folder()`, `webui_set_custom_parameters()` | `Window.openWithBrowser()` accepts a `BrowserLaunchOptions` value with an explicit browser, optional full executable path, and additional argv. An empty argv applies the Chromium default arguments; a non-empty argv replaces them, matching upstream `custom_parameters`. | | `webui_get_child_process_id()` | `Window.openWithBrowser()` returns the retained direct child's `BrowserProcessId`; `Window.browserProcessId()` retrieves it later. | | `webui_get_parent_process_id()` | Root-level `parentProcessId()` returns the current Zig backend's numeric process ID without a redundant window argument. Unsupported process targets return an explicit error. | @@ -508,7 +509,8 @@ Browser discovery, default URL opening, explicit browser selection, custom executable paths and argv, the process-wide backend identifier, per-window direct child identifiers, replacement, and shutdown cleanup are implemented. -Discovery still has the Windows Chromium and macOS resolver gaps listed above. +Discovery covers registered Windows Chromium; the macOS resolver gap listed +above remains. ### Browser window controls diff --git a/src/browser.zig b/src/browser.zig index a7c3bb8..89b0678 100644 --- a/src/browser.zig +++ b/src/browser.zig @@ -668,10 +668,17 @@ fn resolveWindowsExecutable( selected: Browser, ) !?[]u8 { const executable = windowsExecutable(selected); - if (try commandValue(gpa, io, &.{ "where.exe", executable }, null)) |path| return path; - // ponytail: Chrome and Chromium share chrome.exe on Windows; inspect - // installation metadata if standalone Chromium detection becomes needed. - if (selected == .chromium) return null; + if (try commandValue(gpa, io, &.{ "where.exe", executable }, null)) |path| { + if (try windowsChromeMatches(io, selected, path)) return path; + gpa.free(path); + } + // Chromium builds also install and register `chrome.exe`. + const registered = if (selected == .chromium) windowsExecutable(.chrome) else executable; + if (selected == .chromium) + if (try commandValue(gpa, io, &.{ "where.exe", registered }, null)) |path| { + if (try windowsChromeMatches(io, selected, path)) return path; + gpa.free(path); + }; var key_buffer: [160]u8 = undefined; for ([_][]const u8{ "HKCU", "HKLM" }) |root| { @@ -679,18 +686,48 @@ fn resolveWindowsExecutable( &key_buffer, "{s}\\Software\\Microsoft\\Windows\\CurrentVersion\\" ++ "App Paths\\{s}", - .{ root, executable }, + .{ root, registered }, ); - if (try commandValue(gpa, io, &.{ + const path = try commandValue(gpa, io, &.{ "reg.exe", "query", key, "/ve", - }, "REG_SZ")) |path| return path; + }, "REG_SZ") orelse continue; + if (try windowsChromeMatches(io, selected, path)) return path; + gpa.free(path); } return null; } +/// Chrome and Chromium share `chrome.exe`. Like upstream, a Google Chrome +/// install is recognised by the `initial_preferences` or legacy +/// `master_preferences` file Google's installer leaves beside it. Other +/// browsers and a `chromium.exe` always match. +fn windowsChromeMatches(io: std.Io, selected: Browser, executable_path: []const u8) !bool { + const shared = std.ascii.eqlIgnoreCase(std.fs.path.basenameWindows(executable_path), "chrome.exe"); + return switch (selected) { + .chrome => try isGoogleChromeInstall(io, executable_path), + .chromium => !shared or !try isGoogleChromeInstall(io, executable_path), + else => true, + }; +} + +fn isGoogleChromeInstall(io: std.Io, executable_path: []const u8) !bool { + const folder = std.fs.path.dirnameWindows(executable_path) orelse return false; + var path_buffer: [std.fs.max_path_bytes]u8 = undefined; + for ([_][]const u8{ "initial_preferences", "master_preferences" }) |name| { + const path = std.fmt.bufPrint(&path_buffer, "{s}{c}{s}", .{ folder, std.fs.path.sep, name }) catch + return false; + std.Io.Dir.cwd().access(io, path, .{}) catch |err| switch (err) { + error.FileNotFound, error.AccessDenied, error.PermissionDenied => continue, + else => return err, + }; + return true; + } + return false; +} + fn resolveMacosExecutable( gpa: std.mem.Allocator, io: std.Io, @@ -1099,6 +1136,33 @@ test "browser focus has an explicit platform contract" { } } +test "Windows Chrome and Chromium installs are told apart by Google's installer files" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + for ([_][]const u8{ "google/chrome.exe", "google/initial_preferences", "legacy/CHROME.EXE", "legacy/master_preferences", "chromium/chrome.exe", "renamed/chromium.exe" }) |sub_path| { + try tmp.dir.createDirPath(io, std.fs.path.dirname(sub_path).?); + try tmp.dir.writeFile(io, .{ .sub_path = sub_path, .data = "" }); + } + var buffer: [std.fs.max_path_bytes]u8 = undefined; + const Case = struct { path: []const u8, chrome: bool, chromium: bool }; + for ([_]Case{ + .{ .path = "google/chrome.exe", .chrome = true, .chromium = false }, + .{ .path = "legacy/CHROME.EXE", .chrome = true, .chromium = false }, + .{ .path = "chromium/chrome.exe", .chrome = false, .chromium = true }, + // A dedicated executable name needs no installer evidence. + .{ .path = "renamed/chromium.exe", .chrome = false, .chromium = true }, + // A registered path whose folder vanished is never Google Chrome. + .{ .path = "missing/chrome.exe", .chrome = false, .chromium = true }, + }) |case| { + const path = try std.fmt.bufPrint(&buffer, ".zig-cache/tmp/{s}/{s}", .{ tmp.sub_path, case.path }); + try std.testing.expectEqual(case.chrome, try windowsChromeMatches(io, .chrome, path)); + try std.testing.expectEqual(case.chromium, try windowsChromeMatches(io, .chromium, path)); + try std.testing.expect(try windowsChromeMatches(io, .edge, path)); + } + try std.testing.expect(!try windowsChromeMatches(io, .chrome, "chrome.exe")); +} + test "browser candidates and preference order cover every browser" { try std.testing.expectError( error.InvalidUrl, From 941eb54c764faec90016aba27e12e1e4f66deeb0 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 21:54:53 +0800 Subject: [PATCH 18/36] feat(browser): find macOS browser bundles outside /Applications Discovery now also checks ~/Applications and then asks Spotlight for the browser's bundle identifier, which finds bundles wherever LaunchServices registered them. Upstream uses open -R -a for this lookup, which reveals the application in Finder; mdfind has no side effects and a missing or disabled index only yields no result. --- README.md | 10 ++- docs/PURE_ZIG_REFACTOR.md | 9 +-- src/browser.zig | 159 +++++++++++++++++++++++++++++++++----- 3 files changed, 149 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index aecc3d2..fee36d9 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ compile or link the upstream WebUI C library or CivetWeb. support. The core rewrite is substantial, but full upstream behavioral parity is not -complete. A fresh source audit found gaps in macOS browser discovery and -native drag/resize, title and navigation integration. See the +complete. A fresh source audit found gaps in native drag/resize, title and +navigation integration. See the [open semantic gaps](docs/PURE_ZIG_REFACTOR.md#open-semantic-gaps) and the [source comparison](docs/UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). @@ -197,8 +197,10 @@ platform order or `null`. Discovery never opens the selected browser. On Windows it probes `PATH` and the `App Paths` registration. Chrome and Chromium both install `chrome.exe`, so, like upstream, a folder with Google's `initial_preferences` or `master_preferences` file is Google Chrome and any -other `chrome.exe` is Chromium. On macOS it checks the standard -`/Applications` and `/System/Applications` bundles. Other platforms run each executable candidate +other `chrome.exe` is Chromium. On macOS it checks `/Applications`, +`/System/Applications`, and `~/Applications`, then finds the bundle anywhere +else by its bundle identifier through Spotlight's `mdfind`, which may return +nothing when indexing is off. Other platforms run each executable candidate on `PATH` with `--version`, like upstream. `Window.openWithBrowser(&running, options)` launches a selected `Browser` diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index 099fdae..292585d 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -49,7 +49,6 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Area | Remaining behavior, not covered by existing API mappings | |---|---| -| Browser discovery | macOS bundles outside the fixed application directories lack upstream discovery paths. | | Native interaction | Missing GTK custom drag/edge resize, Windows draggable-region setup and resizable frameless host behavior, and Cocoa frameless background movement. | | Native page integration | No upstream page-title-to-host synchronization; no GTK engine-level navigation-policy interception independent of a live bridge. | | Default presentation | F5/context-menu/DevTools policy differences are intentional UI-policy candidates, not proof of missing protocol support. | @@ -64,7 +63,7 @@ Closed after the rescan, each with focused tests in the same change: | Content composition | `Content.site` composes optional embedded HTML, a declinable handler, and a root folder in upstream resolution order; `Window.installContent` replaces resources without navigation. Tests: `site content resolves handler, virtual index, html, folder, and entry in upstream order`, `installed content changes resources without navigating clients`. | | Live window lifecycle | `App.createWindow` serves windows created while running with fresh credentials, folder, and monitor. `App.destroyWindow` unregisters at once (`404` routing, backend close, `1001` on later messages), then cancels the window's handlers, monitor, and managed browser in the background, and frees it after its last connection, request, and deferred reply. This works from the window's own handlers via `Client.window()`, and `Running.stop` finishes pending cleanup. Tests: `windows are created and destroyed while the app runs`, `destroying a window before start frees it at once`, `upgrade admission owns only accepted connections and removes every state`. | | Firefox app mode | Firefox windows without a caller profile get a generated per-window profile; before each launch it receives upstream's `chrome/userChrome.css` toolbar suppression and a rewritten `user.js` (stylesheet support, no default-browser check, close warning, tabs in title bar, or first-run pages, and the `browser.display.document_color_use` high-contrast override). Snap Firefox profiles live under the snap's user directory from the passwd home. A caller profile is never modified, so it rejects `high_contrast = false`. Tests: `generated Firefox profiles receive WebUI app-mode settings`, `Firefox windows launch with a generated app-mode profile`, `Firefox launches reject profile combinations they cannot honour`, `passwd home lookup accepts only well-formed absolute entries`. | -| Browser discovery | Windows tells Chrome from Chromium among `PATH` and `App Paths` `chrome.exe` candidates by Google's `initial_preferences`/`master_preferences` files, like upstream, so registered Chromium is found and never mistaken for Chrome. Test: `Windows Chrome and Chromium installs are told apart by Google's installer files`. | +| Browser discovery | Windows tells Chrome from Chromium among `PATH` and `App Paths` `chrome.exe` candidates by Google's `initial_preferences`/`master_preferences` files, like upstream, so registered Chromium is found and never mistaken for Chrome. macOS adds `~/Applications` and a side-effect-free Spotlight bundle-identifier lookup in place of upstream's Finder-revealing `open -R -a`. Tests: `Windows Chrome and Chromium installs are told apart by Google's installer files`, `macOS bundles resolve from Spotlight output`. | | Entry and custom routing | `Site.entry` redirects the root to a validated relative entry file, and handlers that decline a path (empty `404`) are probed for the entry name or `index.*` with `302` redirects, for both `.site` and `.custom`. Same tests as content composition. | Borrowed custom HTTP handlers can await work before returning through `std.Io`; @@ -427,7 +426,7 @@ Mappings with an explicit remaining gap are partial, not parity-complete: | `webui_set_size()`, `webui_set_position()` | `App.WindowOptions.size` and `.position` set initial geometry. `Window.setSize()` and `Window.setPosition()` persist updates, notify connected clients, replay the latest geometry to later clients, and affect subsequent explicit browser launches. | | `webui_set_high_contrast()`, `webui_is_high_contrast()` | `App.WindowOptions.high_contrast` controls Chromium forced-color support and the generated Firefox profile's `browser.display.document_color_use` preference, with explicit errors for Safari and caller-managed Firefox profiles. Browser-side `webui.isHighContrast()` uses native forced-color and contrast media queries without external programs. | | `webui_open_url()` | `openUrl()` safely passes a non-empty URL as one argument to the platform default opener. | -| `webui_get_best_browser()`, `webui_browser_exist()` | `bestBrowser()` and `browserExists()` discover registered or executable browser candidates through the public `Browser` enum, including registered Windows Chromium. | +| `webui_get_best_browser()`, `webui_browser_exist()` | `bestBrowser()` and `browserExists()` discover registered or executable browser candidates through the public `Browser` enum, including registered Windows Chromium and macOS bundles found by bundle identifier. | | `webui_show_browser()`, `webui_set_browser_folder()`, `webui_set_custom_parameters()` | `Window.openWithBrowser()` accepts a `BrowserLaunchOptions` value with an explicit browser, optional full executable path, and additional argv. An empty argv applies the Chromium default arguments; a non-empty argv replaces them, matching upstream `custom_parameters`. | | `webui_get_child_process_id()` | `Window.openWithBrowser()` returns the retained direct child's `BrowserProcessId`; `Window.browserProcessId()` retrieves it later. | | `webui_get_parent_process_id()` | Root-level `parentProcessId()` returns the current Zig backend's numeric process ID without a redundant window argument. Unsupported process targets return an explicit error. | @@ -509,8 +508,8 @@ Browser discovery, default URL opening, explicit browser selection, custom executable paths and argv, the process-wide backend identifier, per-window direct child identifiers, replacement, and shutdown cleanup are implemented. -Discovery covers registered Windows Chromium; the macOS resolver gap listed -above remains. +Discovery covers registered Windows Chromium and macOS bundles outside the +standard application directories. ### Browser window controls diff --git a/src/browser.zig b/src/browser.zig index 89b0678..3ff6805 100644 --- a/src/browser.zig +++ b/src/browser.zig @@ -733,28 +733,91 @@ fn resolveMacosExecutable( io: std.Io, selected: Browser, ) !?[]u8 { - for ([_][]const u8{ "/Applications", "/System/Applications" }) |root| { - const path = try std.fmt.allocPrint( - gpa, - "{s}/{s}.app/Contents/MacOS/{s}", - .{ - root, - macosApplication(selected), - macosExecutable(selected), - }, - ); - std.Io.Dir.accessAbsolute(io, path, .{ .execute = true }) catch |err| { - gpa.free(path); - switch (err) { - error.FileNotFound, - error.AccessDenied, - error.PermissionDenied, - => continue, - else => return err, - } + const home: ?[]const u8 = if (builtin.link_libc) + if (std.c.getenv("HOME")) |value| std.mem.sliceTo(value, 0) else null + else + null; + const user_applications: ?[]u8 = if (home) |directory| + try std.fmt.allocPrint(gpa, "{s}/Applications", .{directory}) + else + null; + defer if (user_applications) |path| gpa.free(path); + for ([_]?[]const u8{ "/Applications", "/System/Applications", user_applications }) |candidate| { + const root = candidate orelse continue; + if (!std.fs.path.isAbsolutePosix(root)) continue; + const bundle = try std.fmt.allocPrint(gpa, "{s}/{s}.app", .{ root, macosApplication(selected) }); + defer gpa.free(bundle); + if (try macosBundleExecutable(gpa, io, bundle, selected)) |path| return path; + } + // Bundles anywhere else, like upstream's LaunchServices lookup, through + // Spotlight's bundle-identifier index. `open -R -a` is not used: it + // reveals the application in Finder. + var query_buffer: [96]u8 = undefined; + const query = try std.fmt.bufPrint( + &query_buffer, + "kMDItemCFBundleIdentifier == '{s}'", + .{macosBundleIdentifier(selected)}, + ); + const output = try commandOutput(gpa, io, &.{ "mdfind", query }) orelse return null; + defer gpa.free(output); + return spotlightBundleExecutable(gpa, io, output, selected); +} + +/// The first executable browser in `mdfind` output: one absolute `.app` +/// path per line. Unexpected lines are skipped. +fn spotlightBundleExecutable( + gpa: std.mem.Allocator, + io: std.Io, + output: []const u8, + selected: Browser, +) !?[]u8 { + var lines = std.mem.tokenizeAny(u8, output, "\r\n"); + while (lines.next()) |bundle| { + if (!std.fs.path.isAbsolutePosix(bundle) or !std.mem.endsWith(u8, bundle, ".app")) + continue; + if (try macosBundleExecutable(gpa, io, bundle, selected)) |path| return path; + } + return null; +} + +fn macosBundleExecutable( + gpa: std.mem.Allocator, + io: std.Io, + bundle: []const u8, + selected: Browser, +) !?[]u8 { + const path = try std.fmt.allocPrint(gpa, "{s}/Contents/MacOS/{s}", .{ bundle, macosExecutable(selected) }); + std.Io.Dir.accessAbsolute(io, path, .{ .execute = true }) catch |err| { + gpa.free(path); + return switch (err) { + error.FileNotFound, error.AccessDenied, error.PermissionDenied => null, + else => err, }; - return path; + }; + return path; +} + +/// Complete stdout of a successful command, or null when the program is +/// missing or fails. Caller owns the returned memory. +fn commandOutput( + gpa: std.mem.Allocator, + io: std.Io, + argv: []const []const u8, +) !?[]u8 { + const result = std.process.run(gpa, io, .{ + .argv = argv, + .stdout_limit = .limited(64 << 10), + .stderr_limit = .limited(64 << 10), + }) catch |err| switch (err) { + error.FileNotFound, error.AccessDenied, error.InvalidExe => return null, + else => return err, + }; + gpa.free(result.stderr); + switch (result.term) { + .exited => |code| if (code == 0) return result.stdout, + else => {}, } + gpa.free(result.stdout); return null; } @@ -829,6 +892,20 @@ fn macosApplication(selected: Browser) []const u8 { }; } +fn macosBundleIdentifier(selected: Browser) []const u8 { + return switch (selected) { + .chrome => "com.google.Chrome", + .firefox => "org.mozilla.firefox", + .edge => "com.microsoft.edgemac", + .safari => "com.apple.Safari", + .chromium => "org.chromium.Chromium", + .opera => "com.operasoftware.Opera", + .brave => "com.brave.Browser", + .vivaldi => "com.vivaldi.Vivaldi", + .epic => "com.hiddenreflex.Epic", + .yandex => "ru.yandex.desktop.yandex-browser", + }; +} fn macosExecutable(selected: Browser) []const u8 { return switch (selected) { .firefox => "firefox", @@ -1163,6 +1240,48 @@ test "Windows Chrome and Chromium installs are told apart by Google's installer try std.testing.expect(!try windowsChromeMatches(io, .chrome, "chrome.exe")); } +test "macOS bundles resolve from Spotlight output" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDirPath(io, "Custom/Google Chrome.app/Contents/MacOS"); + try tmp.dir.writeFile(io, .{ + .sub_path = "Custom/Google Chrome.app/Contents/MacOS/Google Chrome", + .data = "", + .flags = .{ .permissions = .executable_file }, + }); + try tmp.dir.createDirPath(io, "Plain/Google Chrome.app/Contents/MacOS"); + try tmp.dir.writeFile(io, .{ .sub_path = "Plain/Google Chrome.app/Contents/MacOS/Google Chrome", .data = "" }); + const root = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(root); + const output = try std.fmt.allocPrint( + gpa, + "relative/Google Chrome.app\n\n/not/a/bundle\n{0s}/Missing.app\n{0s}/Plain/Google Chrome.app\n{0s}/Custom/Google Chrome.app\r\n", + .{root}, + ); + defer gpa.free(output); + const found = (try spotlightBundleExecutable(gpa, io, output, .chrome)).?; + defer gpa.free(found); + const expected = try std.fmt.allocPrint(gpa, "{s}/Custom/Google Chrome.app/Contents/MacOS/Google Chrome", .{root}); + defer gpa.free(expected); + try std.testing.expectEqualStrings(expected, found); + try std.testing.expectEqual(@as(?[]u8, null), try spotlightBundleExecutable(gpa, io, "", .chrome)); + try std.testing.expectEqual(@as(?[]u8, null), try spotlightBundleExecutable(gpa, io, output, .firefox)); + // A missing lookup program is an unavailable result, not an error. + try std.testing.expectEqual(@as(?[]u8, null), try commandOutput(gpa, io, &.{"webui-missing-lookup-program"})); + + var identifiers: std.StringHashMapUnmanaged(void) = .empty; + defer identifiers.deinit(gpa); + for (std.enums.values(Browser)) |selected| { + const identifier = macosBundleIdentifier(selected); + try std.testing.expect(std.mem.indexOfScalar(u8, identifier, '.') != null); + try std.testing.expect(std.mem.indexOfScalar(u8, identifier, '\'') == null); + try std.testing.expect(!(try identifiers.getOrPut(gpa, identifier)).found_existing); + } +} + test "browser candidates and preference order cover every browser" { try std.testing.expectError( error.InvalidUrl, From 4ba778163a7ddbe561845d6ecbfcc654ae363c7e Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 22:10:31 +0800 Subject: [PATCH 19/36] fix(native): build the WebView2 backend for aarch64 Windows COM vtable entries and GetProcAddress results are byte-aligned opaque pointers, while function pointers require target alignment on aarch64. Assert that alignment with @alignCast so aarch64-windows compiles. --- src/native/windows.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/native/windows.zig b/src/native/windows.zig index 0555030..89bc981 100644 --- a/src/native/windows.zig +++ b/src/native/windows.zig @@ -38,7 +38,8 @@ const Com = extern struct { vtable: [*]const *const anyopaque, fn method(self: *Com, comptime slot: usize, comptime F: type) F { - return @ptrCast(self.vtable[slot]); + // Function pointers are aligned on targets such as aarch64. + return @ptrCast(@alignCast(self.vtable[slot])); } fn retain(self: *Com) void { _ = self.method(1, *const fn (*Com) callconv(.winapi) u32)(self); @@ -295,7 +296,7 @@ pub const Backend = struct { loader.* = .{ .module = module }; self.loader = loader; const create_environment: *const fn (?[*:0]const u16, ?[*:0]const u16, ?*Com, *Completion) callconv(.winapi) HRESULT = - @ptrCast(GetProcAddress(module, "CreateCoreWebView2EnvironmentWithOptions") orelse return error.NativeRuntimeNotFound); + @ptrCast(@alignCast(GetProcAddress(module, "CreateCoreWebView2EnvironmentWithOptions") orelse return error.NativeRuntimeNotFound)); self.instance = GetModuleHandleW(null) orelse return error.NativeInitializationFailed; var class_buffer: [64]u8 = undefined; const class_name = try std.fmt.bufPrint(&class_buffer, "PureZigWebUI-{x}", .{@intFromPtr(self)}); From 3500f24734efe9ff4cf743ee802521d25716b209 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 22:10:39 +0800 Subject: [PATCH 20/36] refactor(native): share one WebView2 event handler implementation The WebMessageReceived close handler becomes an instance of a generic COM event handler parameterized by interface ID and callback, so further ICoreWebView2 events reuse the same reference counting and owner detachment instead of duplicating them. --- src/native/windows.zig | 107 +++++++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 46 deletions(-) diff --git a/src/native/windows.zig b/src/native/windows.zig index 89bc981..1fa62cd 100644 --- a/src/native/windows.zig +++ b/src/native/windows.zig @@ -193,51 +193,67 @@ const ScriptCompletion = struct { } }; -const CloseCallback = struct { - vtable: *const Vtable = &vtable_value, - refs: std.atomic.Value(u32) = .init(1), - owner: ?*Backend, - const Vtable = extern struct { - query: *const fn (*CloseCallback, *const GUID, *?*anyopaque) callconv(.winapi) HRESULT, - add_ref: *const fn (*CloseCallback) callconv(.winapi) u32, - release: *const fn (*CloseCallback) callconv(.winapi) u32, - invoke: *const fn (*CloseCallback, ?*Com, ?*Com) callconv(.winapi) HRESULT, - }; - const vtable_value: Vtable = .{ .query = query, .add_ref = addRef, .release = release, .invoke = invoke }; - fn query(self: *CloseCallback, iid: *const GUID, out: *?*anyopaque) callconv(.winapi) HRESULT { - out.* = null; - if (!std.mem.eql(u8, std.mem.asBytes(iid), std.mem.asBytes(&iid_close_callback)) and - !std.mem.eql(u8, std.mem.asBytes(iid), std.mem.asBytes(&iid_unknown))) - return @bitCast(@as(u32, 0x80004002)); - out.* = self; - _ = addRef(self); - return 0; - } - fn addRef(self: *CloseCallback) callconv(.winapi) u32 { - return self.refs.fetchAdd(1, .monotonic) + 1; - } - fn release(self: *CloseCallback) callconv(.winapi) u32 { - const remaining = self.refs.fetchSub(1, .acq_rel) - 1; - if (remaining == 0) std.heap.page_allocator.destroy(self); - return remaining; - } - fn invoke(self: *CloseCallback, _: ?*Com, args: ?*Com) callconv(.winapi) HRESULT { - const owner = self.owner orelse return 0; - if (owner.closed) return 0; - const message_args = args orelse return 0; - var text: ?[*:0]u16 = null; - const status = message_args.method(5, *const fn (*Com, *?[*:0]u16) callconv(.winapi) HRESULT)(message_args, &text); - defer if (text) |value| CoTaskMemFree(value); - if (status < 0) return 0; // Other application messages need not be strings. - const value = text orelse return 0; - // Fixed command only, with bounded comparison and no URL/code evaluation. - for (close_message, 0..) |character, index| { - if (value[index] != character) return 0; +// ICoreWebView2 event handlers. Each is retained by the runtime per COM rules +// and outlives Backend: releaseWebView clears `owner` before removal. +fn EventCallback(comptime iid: GUID, comptime handle: fn (*Backend, ?*Com) void) type { + return struct { + const Self = @This(); + vtable: *const Vtable = &vtable_value, + refs: std.atomic.Value(u32) = .init(1), + owner: ?*Backend, + const Vtable = extern struct { + query: *const fn (*Self, *const GUID, *?*anyopaque) callconv(.winapi) HRESULT, + add_ref: *const fn (*Self) callconv(.winapi) u32, + release: *const fn (*Self) callconv(.winapi) u32, + invoke: *const fn (*Self, ?*Com, ?*Com) callconv(.winapi) HRESULT, + }; + const vtable_value: Vtable = .{ .query = query, .add_ref = addRef, .release = release, .invoke = invoke }; + fn create(owner: *Backend) !*Self { + const self = try std.heap.page_allocator.create(Self); + self.* = .{ .owner = owner }; + return self; } - if (value[close_message.len] == 0) owner.close_requested = true; - return 0; - } -}; + fn query(self: *Self, requested: *const GUID, out: *?*anyopaque) callconv(.winapi) HRESULT { + out.* = null; + if (!std.mem.eql(u8, std.mem.asBytes(requested), std.mem.asBytes(&iid)) and + !std.mem.eql(u8, std.mem.asBytes(requested), std.mem.asBytes(&iid_unknown))) + return @bitCast(@as(u32, 0x80004002)); + out.* = self; + _ = addRef(self); + return 0; + } + fn addRef(self: *Self) callconv(.winapi) u32 { + return self.refs.fetchAdd(1, .monotonic) + 1; + } + fn release(self: *Self) callconv(.winapi) u32 { + const remaining = self.refs.fetchSub(1, .acq_rel) - 1; + if (remaining == 0) std.heap.page_allocator.destroy(self); + return remaining; + } + fn invoke(self: *Self, _: ?*Com, args: ?*Com) callconv(.winapi) HRESULT { + const owner = self.owner orelse return 0; + if (owner.closed) return 0; + handle(owner, args); + return 0; + } + }; +} + +const CloseCallback = EventCallback(iid_close_callback, closeMessage); + +fn closeMessage(owner: *Backend, args: ?*Com) void { + const message_args = args orelse return; + var text: ?[*:0]u16 = null; + const status = message_args.method(5, *const fn (*Com, *?[*:0]u16) callconv(.winapi) HRESULT)(message_args, &text); + defer if (text) |value| CoTaskMemFree(value); + if (status < 0) return; // Other application messages need not be strings. + const value = text orelse return; + // Fixed command only, with bounded comparison and no URL/code evaluation. + for (close_message, 0..) |character, index| { + if (value[index] != character) return; + } + if (value[close_message.len] == 0) owner.close_requested = true; +} pub const Backend = struct { gpa: std.mem.Allocator, @@ -339,8 +355,7 @@ pub const Backend = struct { defer script_settings.release(); try check(script_settings.method(4, *const fn (*Com, i32) callconv(.winapi) HRESULT)(script_settings, 1)); try check(script_settings.method(6, *const fn (*Com, i32) callconv(.winapi) HRESULT)(script_settings, 1)); - self.close_callback = try std.heap.page_allocator.create(CloseCallback); - self.close_callback.?.* = .{ .owner = self }; + self.close_callback = try CloseCallback.create(self); var token: Token = .{}; try check(webview.method(34, *const fn (*Com, *CloseCallback, *Token) callconv(.winapi) HRESULT)(webview, self.close_callback.?, &token)); self.close_token = token; From f9dba7718995d9fa29c779d87943acb4516e297c Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 22:12:16 +0800 Subject: [PATCH 21/36] feat(native): mirror page titles into the host window title Like upstream, every non-empty page title now replaces the native host title: GTK listens to notify::title, WebView2 to DocumentTitleChanged, and Cocoa observes WKWebView.title through KVO, which also reports document.title changes after load that upstream's didFinishNavigation hook misses. Options.title is the initial title and setTitle applies at once until the page reports another one. An empty title keeps the host title; WebView2 reports its own default for untitled documents. Options.follow_page_title and Window.setFollowPageTitle let hosts keep titles under their own control, and Window.title reads the current host title. The native smoke verifies following, setTitle, empty titles, and a non-following window on every platform. --- README.md | 11 ++++++- docs/PURE_ZIG_REFACTOR.md | 9 ++++-- examples/native/main.zig | 59 ++++++++++++++++++++++++++++++++++++- src/native.zig | 14 +++++++++ src/native/linux.zig | 46 ++++++++++++++++++++++++----- src/native/macos.zig | 58 +++++++++++++++++++++++++++++++++---- src/native/types.zig | 6 ++++ src/native/windows.zig | 61 +++++++++++++++++++++++++++++++++++---- 8 files changed, 240 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index fee36d9..9e47584 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ compile or link the upstream WebUI C library or CivetWeb. support. The core rewrite is substantial, but full upstream behavioral parity is not -complete. A fresh source audit found gaps in native drag/resize, title and +complete. A fresh source audit found gaps in native drag/resize and navigation integration. See the [open semantic gaps](docs/PURE_ZIG_REFACTOR.md#open-semantic-gaps) and the [source comparison](docs/UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). @@ -568,6 +568,15 @@ logical coordinates: content size and outer-window position (Cocoa uses its native lower-left origin). `handle()` returns a borrowed tagged Cocoa, GTK, or Win32 handle, invalid after native close or deinit. +Like upstream, each non-empty page title replaces the host window title, +including later `document.title` changes. `Options.title` is the initial title +and `setTitle` applies at once until the page reports another title. An empty +page title keeps the host title; WebView2 instead reports its own default title +for untitled documents. Set `follow_page_title = false`, or call +`setFollowPageTitle(false)`, to keep the title under host control; re-enabling +applies the current page title. `title(gpa)` returns an owned copy of the host +title. + `setCloseHandler(handler, user_data)` handles OS and JavaScript close requests on the UI thread; return `false` to veto. Document-start native integration keeps a vetoed page and its bridge alive, including after navigation history changes. diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index 292585d..5020397 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -50,7 +50,7 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Area | Remaining behavior, not covered by existing API mappings | |---|---| | Native interaction | Missing GTK custom drag/edge resize, Windows draggable-region setup and resizable frameless host behavior, and Cocoa frameless background movement. | -| Native page integration | No upstream page-title-to-host synchronization; no GTK engine-level navigation-policy interception independent of a live bridge. | +| Native page integration | No GTK engine-level navigation-policy interception independent of a live bridge. | | Default presentation | F5/context-menu/DevTools policy differences are intentional UI-policy candidates, not proof of missing protocol support. | Closed after the rescan, each with focused tests in the same change: @@ -64,6 +64,7 @@ Closed after the rescan, each with focused tests in the same change: | Live window lifecycle | `App.createWindow` serves windows created while running with fresh credentials, folder, and monitor. `App.destroyWindow` unregisters at once (`404` routing, backend close, `1001` on later messages), then cancels the window's handlers, monitor, and managed browser in the background, and frees it after its last connection, request, and deferred reply. This works from the window's own handlers via `Client.window()`, and `Running.stop` finishes pending cleanup. Tests: `windows are created and destroyed while the app runs`, `destroying a window before start frees it at once`, `upgrade admission owns only accepted connections and removes every state`. | | Firefox app mode | Firefox windows without a caller profile get a generated per-window profile; before each launch it receives upstream's `chrome/userChrome.css` toolbar suppression and a rewritten `user.js` (stylesheet support, no default-browser check, close warning, tabs in title bar, or first-run pages, and the `browser.display.document_color_use` high-contrast override). Snap Firefox profiles live under the snap's user directory from the passwd home. A caller profile is never modified, so it rejects `high_contrast = false`. Tests: `generated Firefox profiles receive WebUI app-mode settings`, `Firefox windows launch with a generated app-mode profile`, `Firefox launches reject profile combinations they cannot honour`, `passwd home lookup accepts only well-formed absolute entries`. | | Browser discovery | Windows tells Chrome from Chromium among `PATH` and `App Paths` `chrome.exe` candidates by Google's `initial_preferences`/`master_preferences` files, like upstream, so registered Chromium is found and never mistaken for Chrome. macOS adds `~/Applications` and a side-effect-free Spotlight bundle-identifier lookup in place of upstream's Finder-revealing `open -R -a`. Tests: `Windows Chrome and Chromium installs are told apart by Google's installer files`, `macOS bundles resolve from Spotlight output`. | +| Native page titles | Like upstream, each non-empty page title replaces the host title: GTK `notify::title`, WKWebView `title` KVO (which, unlike upstream's `didFinishNavigation`, also reports later `document.title` changes), and WebView2 `DocumentTitleChanged`. `Options.title` is the initial title and `setTitle` applies at once until the next page title; an empty title keeps the host title, while WebView2 reports its own default for untitled documents. `follow_page_title = false` or `setFollowPageTitle(false)` keeps titles host-controlled; re-enabling applies the current page title. `title()` reads the host title. Test: native smoke titles step on all three platforms. | | Entry and custom routing | `Site.entry` redirects the root to a validated relative entry file, and handlers that decline a path (empty `404`) are probed for the entry name or `index.*` with `302` redirects, for both `.site` and `.custom`. Same tests as content composition. | Borrowed custom HTTP handlers can await work before returning through `std.Io`; @@ -373,6 +374,7 @@ external-browser launch flags. | `webui_set_resizable()`, `webui_set_minimum_size()` | `native.Options` and `native.Window.setResizable()` / `setMinimumSize()`. | | `webui_set_frameless()`, `webui_set_transparent()` | Native options and setters. Windows transparency configures both host composition and WebView background; X11 requires RGBA/compositing. macOS transparency is explicitly unsupported, as upstream's native adapter does not implement it. | | `webui_show_wv()`, `webui_set_close_handler_wv()` | `native.Window.open()` plus `setCloseHandler()`. User/JavaScript close can be vetoed before destroying the page; `native.Window.close()` force-closes. | +| WebView page-title tracking (no public upstream function) | `native.Options.follow_page_title` (default `true`), `native.Window.setFollowPageTitle()`, `setTitle()`, and `title()`. | | `webui_get_hwnd()`, `webui_win32_get_hwnd()` | `native.Window.handle()` returns a borrowed tagged Cocoa/Gtk/Win32 handle, invalid after close/deinit. | ### Browser Bridge APIs @@ -563,8 +565,9 @@ This completes `webui_set_runtime()`. This implements `webui_show_wv()`, `webui_set_close_handler_wv()`, and native handles. A separate ABI/ownership review was performed for every platform; the public API stays Zig-native, and runtime verification remains mandatory. -This does not yet cover upstream native drag/edge resize, page-title tracking -or GTK navigation-policy integration; see the reopened semantic gaps. +Page titles drive the host title as upstream does. This does not yet cover +upstream native drag/edge resize or GTK navigation-policy integration; see the +reopened semantic gaps. ### Parity closure (reopened) diff --git a/examples/native/main.zig b/examples/native/main.zig index 16a32c1..6bf1d32 100644 --- a/examples/native/main.zig +++ b/examples/native/main.zig @@ -84,6 +84,42 @@ fn expectSize(io: std.Io, view: webui.native.Window, size: webui.native.Size) !v return error.NativeClosedEarly; } +fn readTitle(view: webui.native.Window, buffer: []u8) ![]const u8 { + var fixed: std.heap.FixedBufferAllocator = .init(buffer); + return view.title(fixed.allocator()); +} + +/// Pump until `view` shows `expected`, bounded like the other smoke waits. +fn expectTitle(io: std.Io, pump: webui.native.Window, view: webui.native.Window, expected: []const u8) !void { + const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .clock = .awake, .raw = .fromSeconds(5) }); + var buffer: [256]u8 = undefined; + while (try pump.poll()) { + const actual = try readTitle(view, &buffer); + if (std.mem.eql(u8, actual, expected)) return; + if (deadline.compare(.lte, .now(io, .awake))) { + std.log.err("native title {s}, expected {s}", .{ actual, expected }); + return error.NativeTitleMismatch; + } + try std.Io.sleep(io, .fromMilliseconds(10), .awake); + } + return error.NativeClosedEarly; +} + +/// Pump for a settle period and fail if `view` ever leaves `expected`. +fn expectTitleKept(io: std.Io, pump: webui.native.Window, view: webui.native.Window, expected: []const u8) !void { + const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .clock = .awake, .raw = .fromMilliseconds(500) }); + var buffer: [256]u8 = undefined; + while (deadline.compare(.gt, .now(io, .awake))) { + if (!try pump.poll()) return error.NativeClosedEarly; + const actual = try readTitle(view, &buffer); + if (!std.mem.eql(u8, actual, expected)) { + std.log.err("native title changed to {s}, expected {s}", .{ actual, expected }); + return error.NativeTitleChanged; + } + try std.Io.sleep(io, .fromMilliseconds(10), .awake); + } +} + const Evaluation = struct { io: std.Io, window: webui.Window, @@ -210,6 +246,25 @@ fn smoke(io: std.Io, first: *Page, second: *Page) !void { } try workers.await(io); if (!dispatch.rejected) return error.NativeThreadGuardFailed; + // setTitle applies at once; later page titles replace it while following. + try expectTitle(io, a, a, "Pure Zig: owner-thread dispatch passed"); + try evaluate(io, a, first.window, "document.title = 'Page title sync'; return 'titled'", "titled"); + try expectTitle(io, a, a, "Page title sync"); + try a.setTitle("Host title"); + try expectTitle(io, a, a, "Host title"); + // WebView2 substitutes its own default for an empty document title. + if (builtin.os.tag != .windows) { + try evaluate(io, a, first.window, "document.title = ''; return 'emptied'", "emptied"); + try expectTitleKept(io, a, a, "Host title"); + } + try evaluate(io, a, first.window, "document.title = 'After empty'; return 'titled'", "titled"); + try expectTitle(io, a, a, "After empty"); + // The second view opened with follow_page_title = false. + try expectTitle(io, a, b, "Second host title"); + try evaluate(io, a, second.window, "document.title = 'Second page title'; return 'titled'", "titled"); + try expectTitleKept(io, a, b, "Second host title"); + try b.setFollowPageTitle(true); + try expectTitle(io, a, b, "Second page title"); try evaluate(io, a, second.window, "history.pushState({},'', '#second'); window.close(); return 'requested'", "requested"); try pumpUntil(io, a, second, 1); if (!second.reentrant_destroy_rejected) return error.NativeCallbackGuardFailed; @@ -226,7 +281,7 @@ fn smoke(io: std.Io, first: *Page, second: *Page) !void { try evaluate(io, a, first.window, "return await webui.call('ready')", "Connected to Zig"); try a.close(); if (try a.poll()) return error.NativeForceCloseFailed; - std.debug.print("NATIVE SMOKE PASS: bridge, geometry, controls, dispatch, veto, history, multiwindow close\n", .{}); + std.debug.print("NATIVE SMOKE PASS: bridge, geometry, controls, dispatch, titles, veto, history, multiwindow close\n", .{}); } pub fn main(init: std.process.Init) !void { @@ -269,6 +324,8 @@ pub fn main(init: std.process.Init) !void { std.debug.print("NATIVE READY\n", .{}); if (is_smoke) { options.user_data = &second; + options.title = "Second host title"; + options.follow_page_title = false; var second_view = try webui.native.Window.open(init.gpa, init.io, second.window, &running, options); defer second_view.deinit() catch |err| std.log.err("native cleanup: {}", .{err}); second.native = second_view; diff --git a/src/native.zig b/src/native.zig index ac7d4fd..b6a3822 100644 --- a/src/native.zig +++ b/src/native.zig @@ -184,6 +184,20 @@ pub const Window = struct { if (supported) try self.state.backend.setTitle(terminated); } + /// Current host window title. Caller owns the returned UTF-8 copy. + pub fn title(self: Window, gpa: std.mem.Allocator) ![]u8 { + try self.checkOpen(); + if (supported) return self.state.backend.title(gpa); + return error.UnsupportedPlatform; + } + + /// Enable or disable page-title mirroring. Enabling applies the current + /// non-empty page title at once; disabling keeps the current host title. + pub fn setFollowPageTitle(self: Window, value: bool) !void { + try self.checkOpen(); + if (supported) try self.state.backend.setFollowPageTitle(value); + } + pub fn navigate(self: Window, value: []const u8) !void { try self.checkOpen(); try validateUrl(value); diff --git a/src/native/linux.zig b/src/native/linux.zig index 21a9535..8ad7199 100644 --- a/src/native/linux.zig +++ b/src/native/linux.zig @@ -51,6 +51,7 @@ const Api = struct { gtk_widget_get_window: *const fn (Object) callconv(.c) ?Object, gtk_container_add: *const fn (Object, Object) callconv(.c) void, gtk_window_set_title: *const fn (Object, [*:0]const u8) callconv(.c) void, + gtk_window_get_title: *const fn (Object) callconv(.c) ?[*:0]const u8, gtk_window_set_default_size: *const fn (Object, c_int, c_int) callconv(.c) void, gtk_window_resize: *const fn (Object, c_int, c_int) callconv(.c) void, gtk_window_move: *const fn (Object, c_int, c_int) callconv(.c) void, @@ -94,6 +95,7 @@ const Api = struct { webkit_web_view_get_settings: *const fn (Object) callconv(.c) ?Object, webkit_settings_set_enable_javascript: *const fn (Object, c_int) callconv(.c) void, webkit_web_view_load_uri: *const fn (Object, [*:0]const u8) callconv(.c) void, + webkit_web_view_get_title: *const fn (Object) callconv(.c) ?[*:0]const u8, webkit_web_view_stop_loading: *const fn (Object) callconv(.c) void, webkit_web_view_set_background_color: *const fn (Object, *const Rgba) callconv(.c) void, webkit_web_view_get_user_content_manager: *const fn (Object) callconv(.c) ?Object, @@ -156,7 +158,7 @@ pub const Backend = struct { message_signal: c_ulong = 0, message_registered: bool = false, window_signals: [3]c_ulong = .{ 0, 0, 0 }, - view_signals: [1]c_ulong = .{0}, + view_signals: [2]c_ulong = .{ 0, 0 }, close_handler: ?types.CloseHandler, user_data: ?*anyopaque, closed: bool = false, @@ -168,6 +170,7 @@ pub const Backend = struct { rgba_visual: bool = false, x11: bool = false, minimum_size: ?types.Size = null, + follow_page_title: bool, pub fn create(gpa: std.mem.Allocator, io: std.Io, url: [:0]const u8, options: types.Options) !*Backend { if (options.webview2_loader != null) return error.UnsupportedNativeControl; @@ -184,7 +187,7 @@ pub const Backend = struct { if (api.gtk_init_check(null, null) == 0) return error.NativeDisplayUnavailable; const self = try gpa.create(Backend); errdefer gpa.destroy(self); - self.* = .{ .gpa = gpa, .gtk = gtk, .webkit = webkit, .api = api, .close_handler = options.close_handler, .user_data = options.user_data }; + self.* = .{ .gpa = gpa, .gtk = gtk, .webkit = webkit, .api = api, .close_handler = options.close_handler, .user_data = options.user_data, .follow_page_title = options.follow_page_title }; errdefer self.releaseObjects(); const window = api.gtk_window_new(0) orelse return error.NativeInitializationFailed; @@ -243,12 +246,13 @@ pub const Backend = struct { self.window_signals[1] = try self.connect(window, "destroy", @ptrCast(&destroyed)); self.window_signals[2] = try self.connect(window, "draw", @ptrCast(&draw)); self.view_signals[0] = try self.connect(view, "web-process-terminated", @ptrCast(&processTerminated)); + self.view_signals[1] = try self.connect(view, "notify::title", @ptrCast(&titleChanged)); // GTK/WebKit setters copy strings synchronously; no borrowed Options // slices or URL buffers are retained in this backend. - const title = try gpa.dupeZ(u8, options.title); - defer gpa.free(title); - api.gtk_window_set_title(window, title); + const initial_title = try gpa.dupeZ(u8, options.title); + defer gpa.free(initial_title); + api.gtk_window_set_title(window, initial_title); api.gtk_window_set_default_size(window, @intCast(options.size.width), @intCast(options.size.height)); if (options.minimum_size) |minimum| try self.setMinimumSize(minimum); try self.setResizable(options.resizable); @@ -288,7 +292,7 @@ pub const Backend = struct { } if (self.view) |view| { for (self.view_signals) |signal| self.disconnect(view, signal); - self.view_signals = .{0}; + self.view_signals = .{ 0, 0 }; // A retained GtkWidget pointer can already have been disposed by // gtk_widget_destroy; only call WebKit methods before that point. if (!self.closed) self.api.webkit_web_view_stop_loading(view); @@ -344,8 +348,29 @@ pub const Backend = struct { return self.window orelse error.NativeWindowClosed; } - pub fn setTitle(self: *Backend, title: [:0]const u8) !void { - self.api.gtk_window_set_title(try self.liveWindow(), title); + pub fn setTitle(self: *Backend, value: [:0]const u8) !void { + self.api.gtk_window_set_title(try self.liveWindow(), value); + } + + pub fn title(self: *Backend, gpa: std.mem.Allocator) ![]u8 { + const value = self.api.gtk_window_get_title(try self.liveWindow()) orelse ""; + return gpa.dupe(u8, std.mem.sliceTo(value, 0)); + } + + pub fn setFollowPageTitle(self: *Backend, value: bool) !void { + _ = try self.liveWindow(); + self.follow_page_title = value; + if (value) self.applyPageTitle(); + } + + fn applyPageTitle(self: *Backend) void { + if (!self.follow_page_title or self.closed or self.close_requested) return; + const window = self.window orelse return; + // WebKitGTK reports NULL before a title exists; an empty title also + // keeps the current host title instead of blanking the window. + const value = self.api.webkit_web_view_get_title(self.view orelse return) orelse return; + if (value[0] == 0) return; + self.api.gtk_window_set_title(window, value); } pub fn navigate(self: *Backend, url: [:0]const u8) !void { @@ -547,6 +572,11 @@ pub const Backend = struct { self.removePendingClose(); } + fn titleChanged(_: Object, _: Object, data: ?Object) callconv(.c) void { + const self: *Backend = @ptrCast(@alignCast(data.?)); + self.applyPageTitle(); + } + fn processTerminated(_: Object, _: c_int, data: ?Object) callconv(.c) void { const self: *Backend = @ptrCast(@alignCast(data.?)); self.process_failed = true; diff --git a/src/native/macos.zig b/src/native/macos.zig index cd71312..cc5aa82 100644 --- a/src/native/macos.zig +++ b/src/native/macos.zig @@ -127,7 +127,8 @@ fn registerClasses() !void { !truth(class_addMethod(class, sel_registerName("windowShouldClose:"), @ptrCast(&windowShouldClose), if (ObjcBool == bool) "B@:@" else "c@:@")) or !truth(class_addMethod(class, sel_registerName("windowWillClose:"), @ptrCast(&windowWillClose), "v@:@")) or !truth(class_addMethod(class, sel_registerName("webViewDidClose:"), @ptrCast(&webViewDidClose), "v@:@")) or - !truth(class_addMethod(class, sel_registerName("userContentController:didReceiveScriptMessage:"), @ptrCast(&didReceiveScriptMessage), "v@:@@"))) + !truth(class_addMethod(class, sel_registerName("userContentController:didReceiveScriptMessage:"), @ptrCast(&didReceiveScriptMessage), "v@:@@")) or + !truth(class_addMethod(class, sel_registerName("observeValueForKeyPath:ofObject:change:context:"), @ptrCast(&observeValue), "v@:@@@^v"))) return error.NativeInitializationFailed; objc_registerClassPair(class); delegate_class = class; @@ -169,6 +170,12 @@ fn webViewDidClose(delegate: Id, _: Sel, webview: Id) callconv(.c) void { // The document-start override below handles ordinary JS close requests. self.pending_close = true; } +fn observeValue(delegate: Id, _: Sel, _: Id, object: Id, _: Id, _: ?*anyopaque) callconv(.c) void { + // Only the WKWebView `title` key path is registered with this observer. + const self = context(delegate) orelse return; + if (object != self.webview) return; + self.applyPageTitle(); +} fn didReceiveScriptMessage(delegate: Id, _: Sel, controller: Id, message: Id) callconv(.c) void { const self = context(delegate) orelse return; if (self.closed or self.pending_close or controller != self.content_controller or @@ -215,6 +222,11 @@ pub const Backend = struct { kiosk_frame: Rect = undefined, kiosk_level: isize = 0, minimum_size: types.Size = .{ .width = 1, .height = 1 }, + follow_page_title: bool, + // +1 key path. KVO does not retain the observer; destroy removes it once + // with this retained key, so teardown never allocates. + title_key: Id = null, + title_observed: bool = false, pub fn create(gpa: std.mem.Allocator, io: std.Io, url: [:0]const u8, options: types.Options) !*Backend { _ = io; // No asynchronous initialization or waiting: loadRequest starts navigation. @@ -246,6 +258,7 @@ pub const Backend = struct { .user_data = options.user_data, .resizable = options.resizable, .frameless = options.frameless, + .follow_page_title = options.follow_page_title, }; errdefer self.destroy(); self.delegate = send0(Id, send0(Id, delegate_class, "alloc"), "init") orelse return error.NativeInitializationFailed; @@ -281,9 +294,14 @@ pub const Backend = struct { send1(void, self.webview, "setUIDelegate:", Id, self.delegate); send1(void, self.webview, "setAutoresizingMask:", usize, 2 | 16); send1(void, self.window, "setContentView:", Id, self.webview); - const title = try string(options.title); - defer release(title); - send1(void, self.window, "setTitle:", Id, title); + // WKWebView's title is KVO-compliant and also reports document.title + // changes after load, which upstream's didFinishNavigation misses. + self.title_key = try string("title"); + send4(void, self.webview, "addObserver:forKeyPath:options:context:", Id, self.delegate, Id, self.title_key, usize, 1, ?*anyopaque, null); + self.title_observed = true; + const initial_title = try string(options.title); + defer release(initial_title); + send1(void, self.window, "setTitle:", Id, initial_title); if (options.minimum_size) |minimum| try self.setMinimumSize(minimum); if (options.position) |position| try self.setPosition(position); if (options.center) try self.center(); @@ -310,6 +328,10 @@ pub const Backend = struct { // reference before releasing the controller/webview and our owning refs. if (self.delegate != null) _ = object_setInstanceVariable(self.delegate, context_ivar, null); + if (self.title_observed) { + send2(void, self.webview, "removeObserver:forKeyPath:", Id, self.delegate, Id, self.title_key); + self.title_observed = false; + } if (self.message_name != null) send1(void, self.content_controller, "removeScriptMessageHandlerForName:", Id, self.message_name); send0(void, self.content_controller, "removeAllUserScripts"); @@ -322,6 +344,7 @@ pub const Backend = struct { release(self.content_controller); release(self.message_body); release(self.message_name); + release(self.title_key); release(self.window); release(self.delegate); self.gpa.destroy(self); @@ -383,14 +406,37 @@ pub const Backend = struct { self.closed = true; self.pending_close = false; } - pub fn setTitle(self: *Backend, title: [:0]const u8) !void { + pub fn setTitle(self: *Backend, value: [:0]const u8) !void { try self.requireOpen(); const autorelease_pool = pool(); defer drain(autorelease_pool); - const text = try string(title); + const text = try string(value); defer release(text); send1(void, self.window, "setTitle:", Id, text); } + pub fn title(self: *Backend, gpa: std.mem.Allocator) ![]u8 { + try self.requireOpen(); + const autorelease_pool = pool(); + defer drain(autorelease_pool); + const value = send0(Id, self.window, "title") orelse return gpa.dupe(u8, ""); + // UTF8String is autoreleased; copy it before draining the pool. + const bytes = send0(?[*:0]const u8, value, "UTF8String") orelse return error.InvalidNativeText; + return gpa.dupe(u8, std.mem.sliceTo(bytes, 0)); + } + pub fn setFollowPageTitle(self: *Backend, value: bool) !void { + try self.requireOpen(); + self.follow_page_title = value; + if (value) self.applyPageTitle(); + } + fn applyPageTitle(self: *Backend) void { + if (!self.follow_page_title or self.closed) return; + const autorelease_pool = pool(); + defer drain(autorelease_pool); + // nil before a document title exists; empty keeps the host title. + const value = send0(Id, self.webview, "title") orelse return; + if (send0(usize, value, "length") == 0) return; + send1(void, self.window, "setTitle:", Id, value); + } pub fn navigate(self: *Backend, url: [:0]const u8) !void { try self.requireOpen(); const autorelease_pool = pool(); diff --git a/src/native/types.zig b/src/native/types.zig index 526917b..0ce286e 100644 --- a/src/native/types.zig +++ b/src/native/types.zig @@ -22,7 +22,11 @@ pub const Handle = union(enum) { pub const CloseHandler = *const fn (?*anyopaque) bool; pub const Options = struct { + /// Initial host title, shown until the page reports a non-empty title. title: []const u8 = "WebUI", + /// Mirror each non-empty page title into the host window title, like + /// upstream. It replaces any earlier `title` or `setTitle` value. + follow_page_title: bool = true, size: Size = .{ .width = 800, .height = 600 }, position: ?Position = null, minimum_size: ?Size = null, @@ -69,6 +73,8 @@ pub fn validateSize(value: Size) !void { } test "native options reject unsafe sizes and contradictory placement" { + // Page titles drive the host title by default, matching upstream. + try std.testing.expect((Options{}).follow_page_title); try (Options{ .position = .{ .x = -100, .y = 0 } }).validate(); try std.testing.expectError(error.InvalidWindowSize, (Options{ .size = .{ .width = 0, .height = 1 } }).validate()); try std.testing.expectError(error.InvalidWindowSize, validateSize(.{ .width = std.math.maxInt(u32), .height = 1 })); diff --git a/src/native/windows.zig b/src/native/windows.zig index 1fa62cd..6a9ca47 100644 --- a/src/native/windows.zig +++ b/src/native/windows.zig @@ -16,6 +16,7 @@ const iid_unknown = GUID.parse("{00000000-0000-0000-c000-000000000046}"); const iid_environment_callback = GUID.parse("{4e8a3389-c9d8-4bd2-b6b5-124fee6cc14d}"); const iid_controller_callback = GUID.parse("{6c4819f3-c9b7-4260-8127-c9f5bde7f68c}"); const iid_close_callback = GUID.parse("{57213f19-00e6-49fa-8e07-898ea01ecbd2}"); // WebMessageReceived +const iid_title_callback = GUID.parse("{f5f2b923-953e-4042-9f95-f3a118e1afd4}"); // DocumentTitleChanged const iid_script_callback = GUID.parse("{b99369f3-9b11-47b5-bc6f-8e7895fcea17}"); const iid_controller2 = GUID.parse("{c979903e-d4ca-4228-92eb-47ee3fa96eab}"); @@ -240,6 +241,7 @@ fn EventCallback(comptime iid: GUID, comptime handle: fn (*Backend, ?*Com) void) } const CloseCallback = EventCallback(iid_close_callback, closeMessage); +const TitleCallback = EventCallback(iid_title_callback, titleChanged); fn closeMessage(owner: *Backend, args: ?*Com) void { const message_args = args orelse return; @@ -255,6 +257,10 @@ fn closeMessage(owner: *Backend, args: ?*Com) void { if (value[close_message.len] == 0) owner.close_requested = true; } +fn titleChanged(owner: *Backend, _: ?*Com) void { + owner.applyPageTitle(); +} + pub const Backend = struct { gpa: std.mem.Allocator, loader: ?*Loader = null, @@ -267,6 +273,9 @@ pub const Backend = struct { webview: ?*Com = null, close_callback: ?*CloseCallback = null, close_token: ?Token = null, + title_callback: ?*TitleCallback = null, + title_token: ?Token = null, + follow_page_title: bool, close_handler: ?types.CloseHandler, user_data: ?*anyopaque, close_requested: bool = false, @@ -292,6 +301,7 @@ pub const Backend = struct { .resizable = options.resizable, .frameless = options.frameless, .transparent = options.transparent, + .follow_page_title = options.follow_page_title, }; errdefer self.destroy(); self.next = live_windows; @@ -327,13 +337,13 @@ pub const Backend = struct { }; self.class_atom = RegisterClassExW(&wc); if (self.class_atom == 0) return error.NativeInitializationFailed; - const title = try std.unicode.utf8ToUtf16LeAllocZ(gpa, options.title); - defer gpa.free(title); + const initial_title = try std.unicode.utf8ToUtf16LeAllocZ(gpa, options.title); + defer gpa.free(initial_title); const outer = try self.outerSize(options.size); const initial = options.position orelse types.Position{ .x = std.math.minInt(i32), .y = std.math.minInt(i32) }; // Redirection surfaces cannot be toggled after HWND creation. Reserve // the composition host up front; WebView2's background controls opacity. - self.hwnd = CreateWindowExW(0x00200000, class_wide.ptr, title.ptr, self.style(), initial.x, initial.y, outer.x, outer.y, null, null, self.instance, self) orelse return error.NativeWindowCreationFailed; + self.hwnd = CreateWindowExW(0x00200000, class_wide.ptr, initial_title.ptr, self.style(), initial.x, initial.y, outer.x, outer.y, null, null, self.instance, self) orelse return error.NativeWindowCreationFailed; const profile = if (options.profile_directory) |path| try std.unicode.utf8ToUtf16LeAllocZ(gpa, path) else null; defer if (profile) |path| gpa.free(path); const start = std.Io.Clock.awake.now(io); @@ -359,6 +369,10 @@ pub const Backend = struct { var token: Token = .{}; try check(webview.method(34, *const fn (*Com, *CloseCallback, *Token) callconv(.winapi) HRESULT)(webview, self.close_callback.?, &token)); self.close_token = token; + self.title_callback = try TitleCallback.create(self); + var title_token: Token = .{}; + try check(webview.method(46, *const fn (*Com, *TitleCallback, *Token) callconv(.winapi) HRESULT)(webview, self.title_callback.?, &title_token)); + self.title_token = title_token; // WindowCloseRequested is too late to guarantee veto or repeat requests; // Chromium can also refuse native window.close after history navigation. // Replace it before page scripts run, without marking the page closed. @@ -421,11 +435,16 @@ pub const Backend = struct { fn releaseWebView(self: *Backend) void { if (self.close_callback) |callback| callback.owner = null; + if (self.title_callback) |callback| callback.owner = null; if (self.webview) |webview| { if (self.close_token) |token| { _ = webview.method(35, *const fn (*Com, Token) callconv(.winapi) HRESULT)(webview, token); self.close_token = null; } + if (self.title_token) |token| { + _ = webview.method(47, *const fn (*Com, Token) callconv(.winapi) HRESULT)(webview, token); + self.title_token = null; + } } if (self.controller) |controller| _ = controller.closeController(); if (self.webview) |webview| webview.release(); @@ -434,6 +453,8 @@ pub const Backend = struct { self.controller = null; if (self.close_callback) |callback| _ = CloseCallback.release(callback); self.close_callback = null; + if (self.title_callback) |callback| _ = TitleCallback.release(callback); + self.title_callback = null; } pub fn pump(self: *Backend) !bool { @@ -502,12 +523,40 @@ pub const Backend = struct { if (GetClientRect(try self.window(), &rect) == 0) return error.NativeOperationFailed; try check(controller.method(6, *const fn (*Com, RECT) callconv(.winapi) HRESULT)(controller, rect)); } - pub fn setTitle(self: *Backend, title: [:0]const u8) !void { + pub fn setTitle(self: *Backend, value: [:0]const u8) !void { const hwnd = try self.window(); - const text = try std.unicode.utf8ToUtf16LeAllocZ(self.gpa, title); + const text = try std.unicode.utf8ToUtf16LeAllocZ(self.gpa, value); defer self.gpa.free(text); if (SetWindowTextW(hwnd, text.ptr) == 0) return error.NativeOperationFailed; } + pub fn title(self: *Backend, gpa: std.mem.Allocator) ![]u8 { + const hwnd = try self.window(); + const length = GetWindowTextLengthW(hwnd); + if (length <= 0) return gpa.dupe(u8, ""); + const buffer = try gpa.alloc(u16, @as(usize, @intCast(length)) + 1); + defer gpa.free(buffer); + const copied = GetWindowTextW(hwnd, buffer.ptr, @intCast(buffer.len)); + if (copied < 0) return error.NativeOperationFailed; + return std.unicode.utf16LeToUtf8Alloc(gpa, buffer[0..@intCast(copied)]) catch error.InvalidNativeText; + } + pub fn setFollowPageTitle(self: *Backend, value: bool) !void { + _ = try self.window(); + self.follow_page_title = value; + if (value) self.applyPageTitle(); + } + fn applyPageTitle(self: *Backend) void { + if (!self.follow_page_title or self.closed) return; + const hwnd = self.hwnd orelse return; + const webview = self.webview orelse return; + var text: ?[*:0]u16 = null; + const status = webview.method(48, *const fn (*Com, *?[*:0]u16) callconv(.winapi) HRESULT)(webview, &text); + defer if (text) |value| CoTaskMemFree(value); + if (status < 0) return; + const value = text orelse return; + // An empty document title keeps the current host title. + if (value[0] == 0) return; + _ = SetWindowTextW(hwnd, value); + } pub fn navigate(self: *Backend, url: [:0]const u8) !void { _ = try self.window(); const webview = self.webview orelse return error.NativeWindowClosed; @@ -804,6 +853,8 @@ extern "user32" fn PeekMessageW(*MSG, ?HWND, u32, u32, u32) callconv(.winapi) i3 extern "user32" fn TranslateMessage(*const MSG) callconv(.winapi) i32; extern "user32" fn DispatchMessageW(*const MSG) callconv(.winapi) isize; extern "user32" fn AdjustWindowRectEx(*RECT, u32, i32, u32) callconv(.winapi) i32; +extern "user32" fn GetWindowTextLengthW(HWND) callconv(.winapi) i32; +extern "user32" fn GetWindowTextW(HWND, [*]u16, i32) callconv(.winapi) i32; extern "user32" fn GetClientRect(HWND, *RECT) callconv(.winapi) i32; extern "user32" fn GetWindowRect(HWND, *RECT) callconv(.winapi) i32; extern "user32" fn SetWindowPos(HWND, ?HWND, i32, i32, i32, i32, u32) callconv(.winapi) i32; From 07cece6870f2e90c82ccbc940fa7ef6f49012606 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 22:18:52 +0800 Subject: [PATCH 22/36] fix(native): allow slow WebView2 cold starts to finish initializing Environment, controller, and document-script creation were bounded to 15 s. Hosted Windows runners take about 7 s to create two windows and occasionally exceed 15 s while spawning the browser processes, failing with NativeInitializationTimeout although the runtime is healthy. Raise the bound to 60 s; it still turns a runtime that never answers into an explicit error. --- README.md | 2 +- src/native/windows.zig | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9e47584..2ecc870 100644 --- a/README.md +++ b/README.md @@ -597,7 +597,7 @@ Platform prerequisites and explicit limits: - **Windows:** link `user32`, `gdi32`, `ole32`, `kernel32`, and `dwmapi`; install the WebView2 Runtime and provide the architecture-matching `WebView2Loader.dll` through `Options.webview2_loader` or normal DLL discovery. No runtime is - downloaded by the library. Initialization is bounded to 15 seconds; late COM + downloaded by the library. Initialization is bounded to 60 seconds; late COM callbacks retain safe independent ownership. Transparency requires DWM composition and the Controller2 interface. diff --git a/src/native/windows.zig b/src/native/windows.zig index 6a9ca47..cb292d4 100644 --- a/src/native/windows.zig +++ b/src/native/windows.zig @@ -17,6 +17,10 @@ const iid_environment_callback = GUID.parse("{4e8a3389-c9d8-4bd2-b6b5-124fee6cc1 const iid_controller_callback = GUID.parse("{6c4819f3-c9b7-4260-8127-c9f5bde7f68c}"); const iid_close_callback = GUID.parse("{57213f19-00e6-49fa-8e07-898ea01ecbd2}"); // WebMessageReceived const iid_title_callback = GUID.parse("{f5f2b923-953e-4042-9f95-f3a118e1afd4}"); // DocumentTitleChanged +// Upper bound for environment, controller, and document-script creation. +// Cold starts spawn the browser processes and user data folder; CI runners +// take about 7 s for two windows and occasionally exceed 15 s. +const initialization_timeout_ms = 60_000; const iid_script_callback = GUID.parse("{b99369f3-9b11-47b5-bc6f-8e7895fcea17}"); const iid_controller2 = GUID.parse("{c979903e-d4ca-4228-92eb-47ee3fa96eab}"); @@ -383,7 +387,7 @@ pub const Backend = struct { try check(webview.method(27, *const fn (*Com, [*:0]const u16, *ScriptCompletion) callconv(.winapi) HRESULT)(webview, close_script, script_completion)); while (!script_completion.done) { if (!(try self.pump())) return error.NativeWindowClosed; - if (start.durationTo(std.Io.Clock.awake.now(io)).toMilliseconds() >= 15_000) + if (start.durationTo(std.Io.Clock.awake.now(io)).toMilliseconds() >= initialization_timeout_ms) return error.NativeInitializationTimeout; if (!script_completion.done) try std.Io.sleep(io, .fromMilliseconds(1), .awake); } @@ -400,7 +404,7 @@ pub const Backend = struct { fn awaitCompletion(self: *Backend, io: std.Io, start: std.Io.Timestamp, completion: *Completion) !*Com { while (!completion.done) { if (!(try self.pump())) return error.NativeWindowClosed; - if (start.durationTo(std.Io.Clock.awake.now(io)).toMilliseconds() >= 15_000) + if (start.durationTo(std.Io.Clock.awake.now(io)).toMilliseconds() >= initialization_timeout_ms) return error.NativeInitializationTimeout; if (!completion.done) try std.Io.sleep(io, .fromMilliseconds(1), .awake); } From 48143653a32787bbbe8db6fb43e0c2a1d30f915c Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 22:50:17 +0800 Subject: [PATCH 23/36] test(native): collect tests from the platform backend --- src/native.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/native.zig b/src/native.zig index b6a3822..f996b76 100644 --- a/src/native.zig +++ b/src/native.zig @@ -24,6 +24,16 @@ const Backend = switch (builtin.os.tag) { else => void, }; +test { + // Backend files keep their focused tests beside the platform code. + _ = switch (builtin.os.tag) { + .macos => @import("native/macos.zig"), + .linux => @import("native/linux.zig"), + .windows => @import("native/windows.zig"), + else => void, + }; +} + pub const Task = *const fn (Window, ?*anyopaque) anyerror!void; const QueuedTask = struct { callback: Task, user_data: ?*anyopaque }; const State = struct { From 5eb62dc20ce4747dfda4e713ca41e052b7b11000 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 22:50:45 +0800 Subject: [PATCH 24/36] feat(native): drag and edge-resize frameless GTK windows WebKitGTK has no CSS app-region support. As upstream WebUI does on Linux, a document-start script finds the nearest `--webui-app-region` value and, once the primary button moves over a `drag` area, posts one request on a dedicated script-message channel. The host starts a window-manager move only while the primary button is actually held, so a page cannot move the window by posting on its own. Frameless resizable windows also gain upstream's 6 px edge band: pressing it starts a window-manager resize from the matching edge or corner, and hovering shows the matching resize cursor, restoring the page cursor when leaving the band. --- src/native/linux.zig | 240 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 221 insertions(+), 19 deletions(-) diff --git a/src/native/linux.zig b/src/native/linux.zig index 8ad7199..1363131 100644 --- a/src/native/linux.zig +++ b/src/native/linux.zig @@ -21,7 +21,12 @@ const GeometryHints = extern struct { const Rgba = extern struct { red: f64, green: f64, blue: f64, alpha: f64 }; const Rectangle = extern struct { x: c_int, y: c_int, width: c_int, height: c_int }; const close_channel = "pureZigWebUIClose"; -const close_script_source = +const drag_channel = "pureZigWebUIDrag"; +const message_channels = [_][:0]const u8{ close_channel, drag_channel }; +// WebKitGTK has no CSS app-region support. Like upstream WebUI on Linux, a +// press on an element whose `--webui-app-region` resolves to `drag` requests +// one native move once the primary button starts moving. +const document_script_source = \\(() => { \\ const post = window.webkit.messageHandlers.pureZigWebUIClose.postMessage.bind( \\ window.webkit.messageHandlers.pureZigWebUIClose); @@ -30,7 +35,92 @@ const close_script_source = \\ }); \\ Object.defineProperty(window, "__zigWebuiNativeClose", { value: window.close }); \\})(); + \\(() => { + \\ const handler = window.webkit.messageHandlers.pureZigWebUIDrag; + \\ const post = handler.postMessage.bind(handler); + \\ const region = (element) => { + \\ for (; element; element = element.parentElement) { + \\ const value = getComputedStyle(element).getPropertyValue("--webui-app-region").trim(); + \\ if (value === "drag" || value === "no-drag") return value; + \\ } + \\ return ""; + \\ }; + \\ let armed = false; + \\ let dragging = false; + \\ document.addEventListener("mousedown", (event) => { + \\ dragging = false; + \\ armed = event.button === 0 && event.target instanceof Element && region(event.target) === "drag"; + \\ }); + \\ document.addEventListener("mousemove", (event) => { + \\ if (event.buttons !== 1) { + \\ armed = false; + \\ dragging = false; + \\ return; + \\ } + \\ if (armed && !dragging) { + \\ dragging = true; + \\ post(true); + \\ } + \\ }); + \\ document.addEventListener("mouseup", () => { + \\ armed = false; + \\ dragging = false; + \\ }); + \\})(); ; +// Frameless resize hit area inside the WebView edges, as in upstream WebUI. +const resize_border = 6; +// GdkWindowEdge values; corners are tested before sides. +const Edge = enum(c_int) { north_west = 0, north = 1, north_east = 2, west = 3, east = 4, south_west = 5, south = 6, south_east = 7 }; +const cursor_names = [_][:0]const u8{ "ns-resize", "ew-resize", "nwse-resize", "nesw-resize" }; +/// Resize edge under a WebView-relative point, or null for the interior. +fn hitEdge(width: c_int, height: c_int, x: f64, y: f64) ?Edge { + const right_band: f64 = @floatFromInt(width - resize_border); + const bottom_band: f64 = @floatFromInt(height - resize_border); + const left = x < resize_border; + const right = x >= right_band; + const top = y < resize_border; + const bottom = y >= bottom_band; + if (top and left) return .north_west; + if (top and right) return .north_east; + if (bottom and left) return .south_west; + if (bottom and right) return .south_east; + if (top) return .north; + if (bottom) return .south; + if (left) return .west; + if (right) return .east; + return null; +} + +// GdkEventButton and GdkEventMotion from gdk/gdkevents.h (GTK 3). +const EventButton = extern struct { + type: c_int, + window: ?Object, + send_event: i8, + time: u32, + x: f64, + y: f64, + axes: ?*f64, + state: c_uint, + button: c_uint, + device: ?Object, + x_root: f64, + y_root: f64, +}; +const EventMotion = extern struct { + type: c_int, + window: ?Object, + send_event: i8, + time: u32, + x: f64, + y: f64, + axes: ?*f64, + state: c_uint, + is_hint: i16, + device: ?Object, + x_root: f64, + y_root: f64, +}; const Api = struct { gtk_init_check: *const fn (?*c_int, ?*?[*:null]?[*:0]u8) callconv(.c) c_int, @@ -49,6 +139,7 @@ const Api = struct { gtk_widget_get_realized: *const fn (Object) callconv(.c) c_int, gtk_widget_get_mapped: *const fn (Object) callconv(.c) c_int, gtk_widget_get_window: *const fn (Object) callconv(.c) ?Object, + gtk_widget_add_events: *const fn (Object, c_int) callconv(.c) void, gtk_container_add: *const fn (Object, Object) callconv(.c) void, gtk_window_set_title: *const fn (Object, [*:0]const u8) callconv(.c) void, gtk_window_get_title: *const fn (Object) callconv(.c) ?[*:0]const u8, @@ -68,11 +159,19 @@ const Api = struct { gtk_window_maximize: *const fn (Object) callconv(.c) void, gtk_window_unmaximize: *const fn (Object) callconv(.c) void, gtk_window_present: *const fn (Object) callconv(.c) void, + gtk_window_begin_move_drag: *const fn (Object, c_int, c_int, c_int, u32) callconv(.c) void, + gtk_window_begin_resize_drag: *const fn (Object, c_int, c_int, c_int, c_int, u32) callconv(.c) void, gdk_screen_get_rgba_visual: *const fn (Object) callconv(.c) ?Object, gdk_screen_is_composited: *const fn (Object) callconv(.c) c_int, gdk_display_get_monitor_at_window: *const fn (Object, Object) callconv(.c) ?Object, gdk_monitor_get_workarea: *const fn (Object, *Rectangle) callconv(.c) void, gdk_window_get_frame_extents: *const fn (Object, *Rectangle) callconv(.c) void, + gdk_window_get_device_position: *const fn (Object, Object, ?*c_int, ?*c_int, *c_uint) callconv(.c) ?Object, + gdk_window_set_cursor: *const fn (Object, ?Object) callconv(.c) void, + gdk_display_get_default_seat: *const fn (Object) callconv(.c) ?Object, + gdk_seat_get_pointer: *const fn (Object) callconv(.c) ?Object, + gdk_device_get_position: *const fn (Object, ?*?Object, *c_int, *c_int) callconv(.c) void, + gdk_cursor_new_from_name: *const fn (Object, [*:0]const u8) callconv(.c) ?Object, g_type_check_instance_is_a: *const fn (Object, usize) callconv(.c) c_int, g_object_ref: *const fn (Object) callconv(.c) Object, g_object_ref_sink: *const fn (Object) callconv(.c) Object, @@ -154,11 +253,15 @@ pub const Backend = struct { view: ?Object = null, context: ?Object = null, content_manager: ?Object = null, - close_script: ?Object = null, - message_signal: c_ulong = 0, - message_registered: bool = false, + document_script: ?Object = null, + message_signals: [message_channels.len]c_ulong = @splat(0), + registered_channels: usize = 0, window_signals: [3]c_ulong = .{ 0, 0, 0 }, - view_signals: [2]c_ulong = .{ 0, 0 }, + view_signals: [4]c_ulong = @splat(0), + cursors: [cursor_names.len]?Object = @splat(null), + edge_cursor_shown: bool = false, + frameless: bool = false, + resizable: bool = true, close_handler: ?types.CloseHandler, user_data: ?*anyopaque, closed: bool = false, @@ -233,20 +336,28 @@ pub const Backend = struct { // Intercept at document start instead: veto never closes the DOM page. const content_manager = api.webkit_web_view_get_user_content_manager(view) orelse return error.NativeInitializationFailed; self.content_manager = api.g_object_ref(content_manager); - self.message_signal = try self.connect(content_manager, "script-message-received::" ++ close_channel, @ptrCast(&scriptMessage)); - if (api.webkit_user_content_manager_register_script_message_handler(content_manager, close_channel) == 0) - return error.NativeInitializationFailed; - self.message_registered = true; + self.message_signals[0] = try self.connect(content_manager, "script-message-received::" ++ close_channel, @ptrCast(&scriptMessage)); + self.message_signals[1] = try self.connect(content_manager, "script-message-received::" ++ drag_channel, @ptrCast(&dragMessage)); + for (message_channels) |channel| { + if (api.webkit_user_content_manager_register_script_message_handler(content_manager, channel) == 0) + return error.NativeInitializationFailed; + self.registered_channels += 1; + } // WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, DOCUMENT_START. The manager // installs the script for every navigation; BFCache retains the override. - self.close_script = api.webkit_user_script_new(close_script_source, 1, 0, null, null) orelse return error.NativeInitializationFailed; - api.webkit_user_content_manager_add_script(content_manager, self.close_script.?); + self.document_script = api.webkit_user_script_new(document_script_source, 1, 0, null, null) orelse return error.NativeInitializationFailed; + api.webkit_user_content_manager_add_script(content_manager, self.document_script.?); self.window_signals[0] = try self.connect(window, "delete-event", @ptrCast(&deleteEvent)); self.window_signals[1] = try self.connect(window, "destroy", @ptrCast(&destroyed)); self.window_signals[2] = try self.connect(window, "draw", @ptrCast(&draw)); self.view_signals[0] = try self.connect(view, "web-process-terminated", @ptrCast(&processTerminated)); self.view_signals[1] = try self.connect(view, "notify::title", @ptrCast(&titleChanged)); + // Edge handlers stay connected and consult the current frameless and + // resizable state, so runtime setters need no reconnection. + api.gtk_widget_add_events(view, 4 | 256); // GDK_POINTER_MOTION_MASK | GDK_BUTTON_PRESS_MASK + self.view_signals[2] = try self.connect(view, "button-press-event", @ptrCast(&buttonPressed)); + self.view_signals[3] = try self.connect(view, "motion-notify-event", @ptrCast(&pointerMoved)); // GTK/WebKit setters copy strings synchronously; no borrowed Options // slices or URL buffers are retained in this backend. @@ -276,23 +387,24 @@ pub const Backend = struct { fn releaseObjects(self: *Backend) void { self.removePendingClose(); if (self.content_manager) |manager| { - self.disconnect(manager, self.message_signal); - self.message_signal = 0; - if (self.message_registered) { - self.api.webkit_user_content_manager_unregister_script_message_handler(manager, close_channel); - self.message_registered = false; + for (&self.message_signals) |*signal| { + self.disconnect(manager, signal.*); + signal.* = 0; } - if (self.close_script) |script| { + for (message_channels[0..self.registered_channels]) |channel| + self.api.webkit_user_content_manager_unregister_script_message_handler(manager, channel); + self.registered_channels = 0; + if (self.document_script) |script| { self.api.webkit_user_content_manager_remove_script(manager, script); self.api.webkit_user_script_unref(script); - self.close_script = null; + self.document_script = null; } self.api.g_object_unref(manager); self.content_manager = null; } if (self.view) |view| { for (self.view_signals) |signal| self.disconnect(view, signal); - self.view_signals = .{ 0, 0 }; + self.view_signals = @splat(0); // A retained GtkWidget pointer can already have been disposed by // gtk_widget_destroy; only call WebKit methods before that point. if (!self.closed) self.api.webkit_web_view_stop_loading(view); @@ -313,6 +425,10 @@ pub const Backend = struct { self.api.g_object_unref(context); self.context = null; } + for (&self.cursors) |*cursor| { + if (cursor.*) |value| self.api.g_object_unref(value); + cursor.* = null; + } } pub fn pump(self: *Backend) !bool { @@ -430,10 +546,12 @@ pub const Backend = struct { pub fn setResizable(self: *Backend, value: bool) !void { self.api.gtk_window_set_resizable(try self.liveWindow(), @intFromBool(value)); + self.resizable = value; } pub fn setFrameless(self: *Backend, value: bool) !void { self.api.gtk_window_set_decorated(try self.liveWindow(), @intFromBool(!value)); + self.frameless = value; } pub fn setTransparent(self: *Backend, value: bool) !void { @@ -566,6 +684,73 @@ pub const Backend = struct { self.requestClose(); } + fn dragMessage(_: Object, result: Object, data: ?Object) callconv(.c) void { + const self: *Backend = @ptrCast(@alignCast(data.?)); + const value = self.api.webkit_javascript_result_get_js_value(result) orelse return; + if (self.api.jsc_value_is_boolean(value) == 0 or self.api.jsc_value_to_boolean(value) == 0) return; + self.beginMove(); + } + + /// Hand a page-requested move to the window manager. Pages control this + /// channel, so a request is honored only while the primary button is held. + fn beginMove(self: *Backend) void { + if (self.closed or self.close_requested) return; + const window = self.window orelse return; + const display = self.api.gtk_widget_get_display(window) orelse return; + const native_window = self.api.gtk_widget_get_window(window) orelse return; + const seat = self.api.gdk_display_get_default_seat(display) orelse return; + const pointer = self.api.gdk_seat_get_pointer(seat) orelse return; + var mask: c_uint = 0; + _ = self.api.gdk_window_get_device_position(native_window, pointer, null, null, &mask); + if (mask & (1 << 8) == 0) return; // GDK_BUTTON1_MASK + var x: c_int = 0; + var y: c_int = 0; + self.api.gdk_device_get_position(pointer, null, &x, &y); + self.api.gtk_window_begin_move_drag(window, 1, x, y, 0); // GDK_CURRENT_TIME + } + + fn edgeAt(self: *const Backend, widget: Object, x: f64, y: f64) ?Edge { + if (!self.frameless or !self.resizable or self.closed or self.close_requested) return null; + return hitEdge(self.api.gtk_widget_get_allocated_width(widget), self.api.gtk_widget_get_allocated_height(widget), x, y); + } + + fn cursorFor(self: *Backend, widget: Object, edge: Edge) ?Object { + const index: usize = switch (edge) { + .north, .south => 0, + .west, .east => 1, + .north_west, .south_east => 2, + .north_east, .south_west => 3, + }; + if (self.cursors[index] == null) { + const display = self.api.gtk_widget_get_display(widget) orelse return null; + self.cursors[index] = self.api.gdk_cursor_new_from_name(display, cursor_names[index]); + } + return self.cursors[index]; + } + + fn buttonPressed(widget: Object, event: *const EventButton, data: ?Object) callconv(.c) c_int { + const self: *Backend = @ptrCast(@alignCast(data.?)); + if (event.type != 4 or event.button != 1) return 0; // GDK_BUTTON_PRESS, primary + const edge = self.edgeAt(widget, event.x, event.y) orelse return 0; + const window = self.window orelse return 0; + self.api.gtk_window_begin_resize_drag(window, @intFromEnum(edge), 1, std.math.lossyCast(c_int, event.x_root), std.math.lossyCast(c_int, event.y_root), event.time); + return 1; + } + + fn pointerMoved(widget: Object, event: *const EventMotion, data: ?Object) callconv(.c) c_int { + const self: *Backend = @ptrCast(@alignCast(data.?)); + const native_window = self.api.gtk_widget_get_window(widget) orelse return 0; + const edge = self.edgeAt(widget, event.x, event.y) orelse { + // WebKit caches its own cursor; drop ours so the page's shows again. + if (self.edge_cursor_shown) self.api.gdk_window_set_cursor(native_window, null); + self.edge_cursor_shown = false; + return 0; + }; + self.api.gdk_window_set_cursor(native_window, self.cursorFor(widget, edge)); + self.edge_cursor_shown = true; + return 1; + } + fn destroyed(_: Object, data: ?Object) callconv(.c) void { const self: *Backend = @ptrCast(@alignCast(data.?)); self.closed = true; @@ -594,3 +779,20 @@ pub const Backend = struct { return 0; // Continue GTK's draw so the WebKit child is painted. } }; + +test "frameless resize hit-testing prefers corners within the edge band" { + try std.testing.expectEqual(Edge.north_west, hitEdge(640, 420, 0, 0).?); + try std.testing.expectEqual(Edge.north_east, hitEdge(640, 420, 639, 5.5).?); + try std.testing.expectEqual(Edge.south_west, hitEdge(640, 420, 5.9, 419).?); + try std.testing.expectEqual(Edge.south_east, hitEdge(640, 420, 634, 414).?); + try std.testing.expectEqual(Edge.north, hitEdge(640, 420, 320, 0).?); + try std.testing.expectEqual(Edge.south, hitEdge(640, 420, 320, 414).?); + try std.testing.expectEqual(Edge.west, hitEdge(640, 420, 0, 210).?); + try std.testing.expectEqual(Edge.east, hitEdge(640, 420, 638, 210).?); + // Just inside the 6 px band on each side is interior. + try std.testing.expectEqual(@as(?Edge, null), hitEdge(640, 420, 6, 6)); + try std.testing.expectEqual(@as(?Edge, null), hitEdge(640, 420, 633.9, 413.9)); + // Tiny allocations stay well-defined: every point is on an edge. + try std.testing.expectEqual(Edge.north_west, hitEdge(4, 4, 1, 1).?); + try std.testing.expectEqual(Edge.north_west, hitEdge(0, 0, 0, 0).?); +} From 3e0157c43e6b8f3401b8aef917dc3d32929291c6 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 22:50:52 +0800 Subject: [PATCH 25/36] feat(native): enable WebView2 drag regions and resizable frameless frames Like upstream WebUI (PR #718), enable ICoreWebView2Settings9 non-client region support so CSS `app-region: drag` areas act as the host caption. Runtimes without Settings9 keep working and record that drag regions are unavailable. Resizable frameless windows keep a WS_THICKFRAME sizing border as upstream does; kiosk windows stay plain popups. --- src/native/windows.zig | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/native/windows.zig b/src/native/windows.zig index cb292d4..289399b 100644 --- a/src/native/windows.zig +++ b/src/native/windows.zig @@ -23,6 +23,7 @@ const iid_title_callback = GUID.parse("{f5f2b923-953e-4042-9f95-f3a118e1afd4}"); const initialization_timeout_ms = 60_000; const iid_script_callback = GUID.parse("{b99369f3-9b11-47b5-bc6f-8e7895fcea17}"); const iid_controller2 = GUID.parse("{c979903e-d4ca-4228-92eb-47ee3fa96eab}"); +const iid_settings9 = GUID.parse("{0528a73b-e92d-49f4-927a-e547dddaa37d}"); const close_message = "pure-zig-webui:close-request"; const close_script = blk: { @@ -289,6 +290,7 @@ pub const Backend = struct { resizable: bool, frameless: bool, kiosk: bool = false, + non_client_regions: bool = false, transparent: bool = false, placement: WINDOWPLACEMENT = .{}, previous: ?*Backend = null, @@ -369,6 +371,16 @@ pub const Backend = struct { defer script_settings.release(); try check(script_settings.method(4, *const fn (*Com, i32) callconv(.winapi) HRESULT)(script_settings, 1)); try check(script_settings.method(6, *const fn (*Com, i32) callconv(.winapi) HRESULT)(script_settings, 1)); + // Settings9 makes CSS app-region drag areas act as the host caption + // (upstream WebUI PR #718). Older runtimes report `.none` instead. + var settings9: ?*Com = null; + if (script_settings.method(0, *const fn (*Com, *const GUID, *?*Com) callconv(.winapi) HRESULT)(script_settings, &iid_settings9, &settings9) >= 0) { + if (settings9) |value| { + defer value.release(); + try check(value.method(38, *const fn (*Com, i32) callconv(.winapi) HRESULT)(value, 1)); + self.non_client_regions = true; + } + } self.close_callback = try CloseCallback.create(self); var token: Token = .{}; try check(webview.method(34, *const fn (*Com, *CloseCallback, *Token) callconv(.winapi) HRESULT)(webview, self.close_callback.?, &token)); @@ -509,7 +521,10 @@ pub const Backend = struct { return self.hwnd orelse error.NativeWindowClosed; } fn style(self: *const Backend) u32 { - if (self.kiosk or self.frameless) return 0x80000000 | 0x02000000 | 0x04000000; // POPUP, CLIPCHILDREN, CLIPSIBLINGS + if (self.kiosk) return 0x80000000 | 0x02000000 | 0x04000000; // POPUP, CLIPCHILDREN, CLIPSIBLINGS + // Upstream keeps a sizing frame on resizable frameless windows. + if (self.frameless) return 0x80000000 | 0x02000000 | 0x04000000 | + @as(u32, if (self.resizable) 0x00040000 else 0); // THICKFRAME return 0x00c80000 | 0x00020000 | 0x02000000 | 0x04000000 | @as(u32, if (self.resizable) 0x00050000 else 0); // caption, system menu, minimize, size/maximize } From badeb718de3f0a02d94bcdf73ad8f56df2a6ec0b Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 22:50:55 +0800 Subject: [PATCH 26/36] feat(native): move frameless Cocoa windows by their background Match upstream WebUI's Cocoa adapter: frameless windows are movable by their background. The setting follows frameless changes at creation, style updates, and kiosk transitions; kiosk windows are never movable. --- src/native/macos.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/native/macos.zig b/src/native/macos.zig index cc5aa82..7d9268e 100644 --- a/src/native/macos.zig +++ b/src/native/macos.zig @@ -269,6 +269,7 @@ pub const Backend = struct { // A close removes this window, but must not release our owning ref. send1(void, self.window, "setReleasedWhenClosed:", ObjcBool, yes(false)); send1(void, self.window, "setDelegate:", Id, self.delegate); + self.applyMovable(); // Suppress AppKit automatic tab grouping across independent WebUI windows. if (truth(send1(ObjcBool, self.window, "respondsToSelector:", Sel, sel_registerName("setTabbingMode:")))) send1(void, self.window, "setTabbingMode:", isize, 2); @@ -395,6 +396,12 @@ pub const Backend = struct { send1(void, self.window, "setContentView:", Id, self.webview); send1(void, self.window, "setContentSize:", Size, content.size); send1(void, self.window, "setFrameOrigin:", Point, frame.origin); + self.applyMovable(); + } + fn applyMovable(self: *Backend) void { + // Upstream WebUI: frameless windows move by dragging their background. + // WebKit decides which page points count as background. + send1(void, self.window, "setMovableByWindowBackground:", ObjcBool, yes(self.frameless and !self.kiosk)); } pub fn close(self: *Backend) !void { @@ -534,6 +541,7 @@ pub const Backend = struct { self.kiosk_level = send0(isize, self.window, "level"); self.kiosk = true; send1(void, self.window, "setStyleMask:", usize, self.style()); + self.applyMovable(); // NSMainMenuWindowLevel + 1, from NSWindow.h/CGWindowLevel.h. // Per-window presentation: no app-wide menu/Dock changes or quit. send1(void, self.window, "setLevel:", isize, 25); @@ -542,6 +550,7 @@ pub const Backend = struct { self.kiosk = false; send1(void, self.window, "setLevel:", isize, self.kiosk_level); send1(void, self.window, "setStyleMask:", usize, self.style()); + self.applyMovable(); send2(void, self.window, "setFrame:display:", Rect, self.kiosk_frame, ObjcBool, yes(true)); } } From 83dddd2be7d8a321da8ea20648330065192d4bfc Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 22:51:31 +0800 Subject: [PATCH 27/36] feat(native): report how pages declare window drag regions native.Window.dragRegion() returns the backend's drag model: the `--webui-app-region` property on WebKitGTK, CSS `app-region` on WebView2, background movement on Cocoa, or none on WebView2 runtimes without Settings9, so unsupported drag regions are reported instead of being silently ignored. --- src/native.zig | 8 ++++++++ src/native/linux.zig | 4 ++++ src/native/macos.zig | 3 +++ src/native/types.zig | 12 ++++++++++++ src/native/windows.zig | 4 ++++ 5 files changed, 31 insertions(+) diff --git a/src/native.zig b/src/native.zig index f996b76..3424829 100644 --- a/src/native.zig +++ b/src/native.zig @@ -11,6 +11,7 @@ pub const Size = types.Size; pub const Position = types.Position; pub const Geometry = types.Geometry; pub const Handle = types.Handle; +pub const DragRegion = types.DragRegion; pub const CloseHandler = types.CloseHandler; const supported = switch (builtin.os.tag) { @@ -293,6 +294,13 @@ pub const Window = struct { return error.UnsupportedPlatform; } + /// Report how this window's pages mark areas that move the host window. + pub fn dragRegion(self: Window) !DragRegion { + try self.checkOpen(); + if (supported) return self.state.backend.dragRegion(); + return error.UnsupportedPlatform; + } + pub fn handle(self: Window) !Handle { try self.checkOpen(); if (supported) return self.state.backend.handle(); diff --git a/src/native/linux.zig b/src/native/linux.zig index 1363131..8ec47e7 100644 --- a/src/native/linux.zig +++ b/src/native/linux.zig @@ -544,6 +544,10 @@ pub const Backend = struct { self.minimum_size = value; } + pub fn dragRegion(_: *const Backend) types.DragRegion { + return .webui_property; + } + pub fn setResizable(self: *Backend, value: bool) !void { self.api.gtk_window_set_resizable(try self.liveWindow(), @intFromBool(value)); self.resizable = value; diff --git a/src/native/macos.zig b/src/native/macos.zig index 7d9268e..2d16557 100644 --- a/src/native/macos.zig +++ b/src/native/macos.zig @@ -403,6 +403,9 @@ pub const Backend = struct { // WebKit decides which page points count as background. send1(void, self.window, "setMovableByWindowBackground:", ObjcBool, yes(self.frameless and !self.kiosk)); } + pub fn dragRegion(_: *const Backend) types.DragRegion { + return .window_background; + } pub fn close(self: *Backend) !void { if (self.closed) return; diff --git a/src/native/types.zig b/src/native/types.zig index 0ce286e..9b4f293 100644 --- a/src/native/types.zig +++ b/src/native/types.zig @@ -11,6 +11,18 @@ pub const Geometry = struct { }; /// Borrowed native window, invalid after native close or owner destruction. +/// How pages declare areas that move the host window. +pub const DragRegion = enum { + /// Elements whose computed `--webui-app-region` is `drag` (WebKitGTK). + webui_property, + /// CSS `app-region: drag` or `-webkit-app-region: drag` (WebView2). + css_app_region, + /// No page regions; a frameless window moves by its background (Cocoa). + window_background, + /// This runtime offers no page drag regions (WebView2 before Settings9). + none, +}; + pub const Handle = union(enum) { cocoa: *anyopaque, gtk: *anyopaque, diff --git a/src/native/windows.zig b/src/native/windows.zig index 289399b..51c5528 100644 --- a/src/native/windows.zig +++ b/src/native/windows.zig @@ -634,6 +634,10 @@ pub const Backend = struct { return error.NativeOperationFailed; if (SetWindowPos(hwnd, null, 0, 0, 0, 0, 0x37) == 0) return error.NativeOperationFailed; } + pub fn dragRegion(self: *const Backend) types.DragRegion { + return if (self.non_client_regions) .css_app_region else .none; + } + pub fn setResizable(self: *Backend, value: bool) !void { _ = try self.window(); const old = self.resizable; From 4f7f9232717c01799dfad6431d39228a7de4d581 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 22:51:31 +0800 Subject: [PATCH 28/36] test(native): drag and resize frameless windows with real input The native smoke makes the first window frameless, declares a page-wide drag region, and synthesizes pointer input: xdotool under X11 and mouse_event on Windows, from a worker so the Windows modal move and size loops can run. It checks that a drag moves the window, a right-edge drag widens it, and on GTK that a forged drag message without a held button does not move it. macOS, which has no input synthesis without Accessibility permission, verifies the background-movement setting. CI installs xdotool and passes --require-input so missing input tools fail instead of skipping. --- .github/workflows/parity.yml | 6 +- examples/native/main.zig | 259 ++++++++++++++++++++++++++++++++++- 2 files changed, 259 insertions(+), 6 deletions(-) diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 3394978..5d2c7f2 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -61,7 +61,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.1-0 xvfb xauth dbus-x11 openbox xcompmgr + sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.1-0 xvfb xauth dbus-x11 openbox xcompmgr xdotool - name: Core, bridge and example gates run: | zig version @@ -79,7 +79,7 @@ jobs: xcompmgr -a & compositor=$! trap "kill $wm $compositor 2>/dev/null || true; wait || true" EXIT sleep 1 - zig-out/bin/native --smoke + zig-out/bin/native --smoke --require-input ' env -u DISPLAY -u WAYLAND_DISPLAY zig-out/bin/native --expect-unavailable - name: macOS native WebView gate @@ -91,7 +91,7 @@ jobs: timeout-minutes: 3 shell: pwsh run: | - & zig-out/bin/native.exe --smoke --loader "$env:WEBUI_WEBVIEW2_LOADER" + & zig-out/bin/native.exe --smoke --require-input --loader "$env:WEBUI_WEBVIEW2_LOADER" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } & zig-out/bin/native.exe --expect-unavailable --loader "$env:RUNNER_TEMP/nonexistent-WebView2Loader.dll" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/examples/native/main.zig b/examples/native/main.zig index 6bf1d32..d47334b 100644 --- a/examples/native/main.zig +++ b/examples/native/main.zig @@ -185,7 +185,256 @@ const Dispatch = struct { } }; -fn smoke(io: std.Io, first: *Page, second: *Page) !void { +const Point = struct { x: i32, y: i32 }; + +/// Move from `start` by `delta` in steps, holding the primary button when +/// `press` is set. +const Gesture = struct { start: Point, delta: Point, press: bool = true }; + +const win32 = struct { + const Rect = extern struct { left: i32 = 0, top: i32 = 0, right: i32 = 0, bottom: i32 = 0 }; + extern "user32" fn mouse_event(flags: u32, dx: u32, dy: u32, data: u32, extra: usize) callconv(.winapi) void; + extern "user32" fn GetSystemMetrics(index: c_int) callconv(.winapi) c_int; + extern "user32" fn GetWindowRect(hwnd: *anyopaque, rect: *Rect) callconv(.winapi) c_int; +}; + +const objc = struct { + const Bool = if (builtin.cpu.arch == .aarch64) bool else i8; + extern "objc" fn sel_registerName(name: [*:0]const u8) *anyopaque; + extern "objc" fn objc_msgSend() void; +}; + +fn movableByBackground(view: webui.native.Window) !bool { + const send: *const fn (*anyopaque, *anyopaque) callconv(.c) objc.Bool = @ptrCast(&objc.objc_msgSend); + const value = send((try view.handle()).cocoa, objc.sel_registerName("isMovableByWindowBackground")); + return if (objc.Bool == bool) value else value != 0; +} + +/// Synthesizes pointer input on a worker: Windows' modal move and size loops +/// run inside the UI thread's message dispatch until the button is released. +const Pointer = struct { + io: std.Io, + gesture: Gesture, + failure: ?anyerror = null, + done: std.atomic.Value(bool) = .init(false), + + fn run(self: *Pointer) void { + const result = if (builtin.os.tag == .windows) self.sendInput() else self.xdotool(); + result catch |err| { + self.failure = err; + }; + self.done.store(true, .release); + } + + fn path(self: *const Pointer) [4]Point { + const start = self.gesture.start; + const delta = self.gesture.delta; + return .{ + start, + .{ .x = start.x + @divTrunc(delta.x, 8), .y = start.y + @divTrunc(delta.y, 8) }, + .{ .x = start.x + @divTrunc(delta.x, 2), .y = start.y + @divTrunc(delta.y, 2) }, + .{ .x = start.x + delta.x, .y = start.y + delta.y }, + }; + } + + fn xdotool(self: *Pointer) !void { + const points = self.path(); + var text: [points.len][2][16]u8 = undefined; + var coordinates: [points.len][2][]const u8 = undefined; + for (points, 0..) |point, index| { + coordinates[index][0] = try std.fmt.bufPrint(&text[index][0], "{d}", .{point.x}); + coordinates[index][1] = try std.fmt.bufPrint(&text[index][1], "{d}", .{point.y}); + } + // Pauses let WebKit deliver the press and first move before the + // remaining motion, matching a person starting a drag. A hover + // replaces the button commands with zero-length pauses. + const down: [2][]const u8 = if (self.gesture.press) .{ "mousedown", "1" } else .{ "sleep", "0" }; + const up: [2][]const u8 = if (self.gesture.press) .{ "mouseup", "1" } else .{ "sleep", "0" }; + const argv = [_][]const u8{ + "xdotool", + "mousemove", + coordinates[0][0], + coordinates[0][1], + "sleep", + "0.2", + down[0], + down[1], + "sleep", + "0.2", + "mousemove", + coordinates[1][0], + coordinates[1][1], + "sleep", + "0.4", + "mousemove", + coordinates[2][0], + coordinates[2][1], + "sleep", + "0.2", + "mousemove", + coordinates[3][0], + coordinates[3][1], + "sleep", + "0.4", + up[0], + up[1], + "sleep", + "0.2", + }; + try runTool(self.io, &argv); + } + + fn sendInput(self: *Pointer) !void { + const width = win32.GetSystemMetrics(0); // SM_CXSCREEN + const height = win32.GetSystemMetrics(1); // SM_CYSCREEN + if (width <= 1 or height <= 1) return error.PointerInputUnavailable; + const points = self.path(); + const pauses = [_]u32{ 200, 400, 200, 400 }; + for (points, pauses, 0..) |point, pause, index| { + // MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE uses 0..65535 per axis. + const x: u32 = @intCast(@divTrunc(@as(i64, point.x) * 65535, width - 1)); + const y: u32 = @intCast(@divTrunc(@as(i64, point.y) * 65535, height - 1)); + win32.mouse_event(0x0001 | 0x8000, x, y, 0, 0); + try std.Io.sleep(self.io, .fromMilliseconds(pause), .awake); + if (index == 0 and self.gesture.press) { + win32.mouse_event(0x0002, 0, 0, 0, 0); // MOUSEEVENTF_LEFTDOWN + try std.Io.sleep(self.io, .fromMilliseconds(200), .awake); + } + } + if (self.gesture.press) win32.mouse_event(0x0004, 0, 0, 0, 0); // MOUSEEVENTF_LEFTUP + try std.Io.sleep(self.io, .fromMilliseconds(200), .awake); + } +}; + +fn runTool(io: std.Io, argv: []const []const u8) !void { + var child = std.process.spawn(io, .{ .argv = argv, .stdin = .ignore, .stdout = .ignore, .stderr = .inherit }) catch |err| switch (err) { + error.FileNotFound => return error.PointerInputUnavailable, + else => return err, + }; + switch (try child.wait(io)) { + .exited => |code| if (code != 0) return error.PointerInputFailed, + else => return error.PointerInputFailed, + } +} + +fn pointerInputAvailable(io: std.Io) !bool { + if (builtin.os.tag != .linux) return builtin.os.tag == .windows; + runTool(io, &.{ "xdotool", "version" }) catch |err| switch (err) { + error.PointerInputUnavailable => return false, + else => return err, + }; + return true; +} + +fn perform(io: std.Io, view: webui.native.Window, gesture: Gesture) !void { + var pointer: Pointer = .{ .io = io, .gesture = gesture }; + var workers: std.Io.Group = .init; + defer workers.cancel(io); + try workers.concurrent(io, Pointer.run, .{&pointer}); + const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .clock = .awake, .raw = .fromSeconds(15) }); + while (!pointer.done.load(.acquire)) { + if (!try view.poll()) return error.NativeClosedEarly; + if (deadline.compare(.lte, .now(io, .awake))) return error.PointerInputTimeout; + try std.Io.sleep(io, .fromMilliseconds(5), .awake); + } + try workers.await(io); + if (pointer.failure) |err| return err; +} + +/// Pump until `check` accepts the geometry, bounded like the other waits. +fn expectGeometry(io: std.Io, view: webui.native.Window, context: anytype, comptime check: fn (@TypeOf(context), webui.native.Geometry) bool) !webui.native.Geometry { + const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .clock = .awake, .raw = .fromSeconds(5) }); + while (try view.poll()) { + const actual = try view.geometry(); + if (check(context, actual)) return actual; + if (deadline.compare(.lte, .now(io, .awake))) { + std.log.err("native geometry {any} after pointer input from {any}", .{ actual, context }); + return error.NativeInteractionFailed; + } + try std.Io.sleep(io, .fromMilliseconds(10), .awake); + } + return error.NativeClosedEarly; +} + +fn movedEnough(before: webui.native.Geometry, after: webui.native.Geometry) bool { + // The gesture moves by (80, 60); a drag begins after its first step. + return after.position.x - before.position.x >= 40 and after.position.y - before.position.y >= 30; +} + +fn widenedEnough(before: webui.native.Geometry, after: webui.native.Geometry) bool { + return after.size.width >= before.size.width + 30; +} + +fn rightEdge(view: webui.native.Window, geometry: webui.native.Geometry) !Point { + const middle = geometry.position.y + @as(i32, @intCast(geometry.size.height / 2)); + if (builtin.os.tag == .windows) { + // Geometry reports the outer origin and client size; the WS_THICKFRAME + // sizing border lies outside the client area. + var rect: win32.Rect = .{}; + if (win32.GetWindowRect((try view.handle()).win32, &rect) == 0) return error.NativeGeometryUnavailable; + return .{ .x = rect.right - 2, .y = @divTrunc(rect.top + rect.bottom, 2) }; + } + // A frameless GTK window is its WebView; its edge band is 6 px wide. + return .{ .x = geometry.position.x + @as(i32, @intCast(geometry.size.width)) - 2, .y = middle }; +} + +/// Frameless dragging and edge resizing, with real pointer input where the +/// platform allows synthesizing it. Returns whether input was exercised. +fn frameless(io: std.Io, view: webui.native.Window, page: *Page, require_input: bool) !bool { + try view.setFrameless(true); + try view.setResizable(true); + try view.setSize(.{ .width = 640, .height = 420 }); + try expectSize(io, view, .{ .width = 640, .height = 420 }); + if (builtin.os.tag == .macos) { + // AppKit offers no public input synthesis without Accessibility + // permission; verify the upstream background-move configuration. + if (try view.dragRegion() != .window_background) return error.NativeDragRegionMismatch; + if (!try movableByBackground(view)) return error.NativeFramelessNotMovable; + try view.setFrameless(false); + if (try movableByBackground(view)) return error.NativeFramedWindowMovable; + return false; + } + const expected: webui.native.DragRegion = if (builtin.os.tag == .windows) .css_app_region else .webui_property; + if (try view.dragRegion() != expected) return error.NativeDragRegionMismatch; + if (!try pointerInputAvailable(io)) { + if (require_input) return error.PointerInputUnavailable; + std.debug.print("NATIVE INPUT SKIPPED: xdotool not found; frameless drag and resize not exercised\n", .{}); + try view.setFrameless(false); + return false; + } + try view.setPosition(.{ .x = 160, .y = 120 }); + try view.focus(); + try evaluate(io, view, page.window, "document.documentElement.style.height = '100%'; document.body.style.cssText = 'margin:0;height:100%;--webui-app-region:drag;-webkit-app-region:drag;app-region:drag'; return 'region'", "region"); + const placed = try expectGeometry(io, view, @as(Point, .{ .x = 160, .y = 120 }), struct { + // Window managers may keep a border on undecorated windows. + fn check(target: Point, actual: webui.native.Geometry) bool { + return @abs(actual.position.x - target.x) <= 8 and @abs(actual.position.y - target.y) <= 8; + } + }.check); + const center: Point = .{ + .x = placed.position.x + @as(i32, @intCast(placed.size.width / 2)), + .y = placed.position.y + @as(i32, @intCast(placed.size.height / 2)), + }; + if (builtin.os.tag == .linux) { + // The page controls the drag channel. A forged request without a held + // primary button must not start a window-manager move. + try evaluate(io, view, page.window, "window.webkit.messageHandlers.pureZigWebUIDrag.postMessage(true); return 'forged'", "forged"); + try perform(io, view, .{ .start = center, .delta = .{ .x = 80, .y = 60 }, .press = false }); + const hovered = try view.geometry(); + if (hovered.position.x != placed.position.x or hovered.position.y != placed.position.y) { + std.log.err("native window moved from {any} to {any} without a press", .{ placed, hovered }); + return error.NativeUnrequestedMove; + } + } + try perform(io, view, .{ .start = center, .delta = .{ .x = 80, .y = 60 } }); + const moved = try expectGeometry(io, view, placed, movedEnough); + try perform(io, view, .{ .start = try rightEdge(view, moved), .delta = .{ .x = 60, .y = 0 } }); + _ = try expectGeometry(io, view, moved, widenedEnough); + try view.setFrameless(false); + return true; +} + +fn smoke(io: std.Io, first: *Page, second: *Page, require_input: bool) !void { const a = first.native.?; const b = second.native.?; try pumpUntil(io, a, first, null); @@ -279,20 +528,24 @@ fn smoke(io: std.Io, first: *Page, second: *Page) !void { _ = try a.poll(); if (b.geometry()) |_| return error.NativeSecondaryCloseFailed else |err| if (err != error.NativeWindowClosed) return err; try evaluate(io, a, first.window, "return await webui.call('ready')", "Connected to Zig"); + const interaction = if (try frameless(io, a, first, require_input)) "frameless drag+resize input" else "frameless configuration"; try a.close(); if (try a.poll()) return error.NativeForceCloseFailed; - std.debug.print("NATIVE SMOKE PASS: bridge, geometry, controls, dispatch, titles, veto, history, multiwindow close\n", .{}); + std.debug.print("NATIVE SMOKE PASS: bridge, geometry, controls, dispatch, titles, veto, history, {s}, multiwindow close\n", .{interaction}); } pub fn main(init: std.process.Init) !void { const args = try init.minimal.args.toSlice(init.arena.allocator()); var is_smoke = false; var expect_unavailable = false; + var require_input = false; var options: webui.native.Options = .{ .title = "Pure Zig native WebView" }; var index: usize = 1; while (index < args.len) : (index += 1) { if (std.mem.eql(u8, args[index], "--smoke")) is_smoke = true else if (std.mem.eql(u8, args[index], "--expect-unavailable")) { expect_unavailable = true; + } else if (std.mem.eql(u8, args[index], "--require-input")) { + require_input = true; } else if (std.mem.eql(u8, args[index], "--loader") and index + 1 < args.len) { index += 1; options.webview2_loader = args[index]; @@ -329,7 +582,7 @@ pub fn main(init: std.process.Init) !void { var second_view = try webui.native.Window.open(init.gpa, init.io, second.window, &running, options); defer second_view.deinit() catch |err| std.log.err("native cleanup: {}", .{err}); second.native = second_view; - try smoke(init.io, &first, &second); + try smoke(init.io, &first, &second, require_input); } else { try first_view.run(); } From 887bf0aebc824c0e3969e9c700b4c491edc3cd7a Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 22:57:26 +0800 Subject: [PATCH 29/36] docs: document frameless drag regions and close the native interaction gap Describe each backend's drag-region model, the GTK held-button rule, edge resizing, and portable CSS for pages, and move native interaction from the open semantic gaps to the closed table with its smoke and unit evidence. --- README.md | 27 +++++++++++++++++++++++++-- docs/PURE_ZIG_REFACTOR.md | 10 +++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2ecc870..0a880ad 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ compile or link the upstream WebUI C library or CivetWeb. support. The core rewrite is substantial, but full upstream behavioral parity is not -complete. A fresh source audit found gaps in native drag/resize and +complete. A fresh source audit found a remaining gap in GTK engine-level navigation integration. See the [open semantic gaps](docs/PURE_ZIG_REFACTOR.md#open-semantic-gaps) and the [source comparison](docs/UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). @@ -567,6 +567,27 @@ geometry requests: `setSize`, `setPosition`, `center`, `setMinimumSize`, logical coordinates: content size and outer-window position (Cocoa uses its native lower-left origin). `handle()` returns a borrowed tagged Cocoa, GTK, or Win32 handle, invalid after native close or deinit. +Frameless windows move and resize as upstream's adapters do, and +`dragRegion()` reports how pages mark drag areas on the current backend: +- `.webui_property` (WebKitGTK): pressing an element whose nearest + `--webui-app-region` value is `drag` starts a window-manager move once the + primary button moves; `no-drag` opts descendants out. The host honors a + request only while the primary button is held. Resizable frameless windows + resize from a 6 px edge band and show matching resize cursors. +- `.css_app_region` (WebView2): CSS `app-region: drag` or + `-webkit-app-region: drag` areas act as the caption through Settings9 + non-client regions; runtimes without Settings9 report `.none`. Resizable + frameless windows keep a sizing border. +- `.window_background` (Cocoa): no CSS regions; frameless windows are movable + by their background where WebKit treats the point as background. + +Pages that target every backend declare all three properties: + +```css +.titlebar { --webui-app-region: drag; app-region: drag; -webkit-app-region: drag; } +.titlebar button { --webui-app-region: no-drag; app-region: no-drag; -webkit-app-region: no-drag; } +``` + Like upstream, each non-empty page title replaces the host window title, including later `document.title` changes. `Options.title` is the initial title @@ -619,7 +640,9 @@ External-browser examples warn and shut down when no browser connects. `zig build fuzz --fuzz=100K` exercises bounded protocol parsers. CI installs Node, Deno, and Bun, runs the core and bridge suites, executes native smoke gates -on Linux/macOS/Windows, and cross-builds all five ledger targets. +on Linux/macOS/Windows (with xdotool or `mouse_event` pointer input for +frameless drag and resize on Linux and Windows), and cross-builds all five +ledger targets. The [capability ledger](docs/PURE_ZIG_REFACTOR.md) records implemented behavior, open semantic gaps, and dated cross-platform validation evidence. The earlier diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index 5020397..4ee92a8 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -49,7 +49,6 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Area | Remaining behavior, not covered by existing API mappings | |---|---| -| Native interaction | Missing GTK custom drag/edge resize, Windows draggable-region setup and resizable frameless host behavior, and Cocoa frameless background movement. | | Native page integration | No GTK engine-level navigation-policy interception independent of a live bridge. | | Default presentation | F5/context-menu/DevTools policy differences are intentional UI-policy candidates, not proof of missing protocol support. | @@ -65,6 +64,7 @@ Closed after the rescan, each with focused tests in the same change: | Firefox app mode | Firefox windows without a caller profile get a generated per-window profile; before each launch it receives upstream's `chrome/userChrome.css` toolbar suppression and a rewritten `user.js` (stylesheet support, no default-browser check, close warning, tabs in title bar, or first-run pages, and the `browser.display.document_color_use` high-contrast override). Snap Firefox profiles live under the snap's user directory from the passwd home. A caller profile is never modified, so it rejects `high_contrast = false`. Tests: `generated Firefox profiles receive WebUI app-mode settings`, `Firefox windows launch with a generated app-mode profile`, `Firefox launches reject profile combinations they cannot honour`, `passwd home lookup accepts only well-formed absolute entries`. | | Browser discovery | Windows tells Chrome from Chromium among `PATH` and `App Paths` `chrome.exe` candidates by Google's `initial_preferences`/`master_preferences` files, like upstream, so registered Chromium is found and never mistaken for Chrome. macOS adds `~/Applications` and a side-effect-free Spotlight bundle-identifier lookup in place of upstream's Finder-revealing `open -R -a`. Tests: `Windows Chrome and Chromium installs are told apart by Google's installer files`, `macOS bundles resolve from Spotlight output`. | | Native page titles | Like upstream, each non-empty page title replaces the host title: GTK `notify::title`, WKWebView `title` KVO (which, unlike upstream's `didFinishNavigation`, also reports later `document.title` changes), and WebView2 `DocumentTitleChanged`. `Options.title` is the initial title and `setTitle` applies at once until the next page title; an empty title keeps the host title, while WebView2 reports its own default for untitled documents. `follow_page_title = false` or `setFollowPageTitle(false)` keeps titles host-controlled; re-enabling applies the current page title. `title()` reads the host title. Test: native smoke titles step on all three platforms. | +| Native interaction | `native.Window.dragRegion()` reports each backend's model. WebKitGTK mirrors upstream's `--webui-app-region` script on a dedicated message channel and starts a window-manager move only while the primary button is held; resizable frameless windows resize from upstream's 6 px edge band with resize cursors. WebView2 enables Settings9 non-client regions for CSS `app-region` (reporting `.none` without Settings9), and resizable frameless windows keep `WS_THICKFRAME`. Cocoa frameless windows are movable by background, also across style and kiosk changes. Tests: native smoke frameless step with real pointer input on Linux (xdotool) and Windows (`mouse_event`): drag moves the window, a right-edge drag widens it, and on GTK a forged request without a held button does not move it; macOS reads back `isMovableByWindowBackground` because input synthesis needs Accessibility permission. `frameless resize hit-testing prefers corners within the edge band`. | | Entry and custom routing | `Site.entry` redirects the root to a validated relative entry file, and handlers that decline a path (empty `404`) are probed for the entry name or `index.*` with `302` redirects, for both `.site` and `.custom`. Same tests as content composition. | Borrowed custom HTTP handlers can await work before returning through `std.Io`; @@ -372,7 +372,7 @@ external-browser launch flags. | `webui_set_hide()` | `App.WindowOptions.hide` for headless external browsers; `native.Options.hidden` and `native.Window.setVisible()` for native windows. | | `webui_minimize()`, `webui_maximize()` | `native.Window.minimize()`, `maximize()`, and `restore()`. | | `webui_set_resizable()`, `webui_set_minimum_size()` | `native.Options` and `native.Window.setResizable()` / `setMinimumSize()`. | -| `webui_set_frameless()`, `webui_set_transparent()` | Native options and setters. Windows transparency configures both host composition and WebView background; X11 requires RGBA/compositing. macOS transparency is explicitly unsupported, as upstream's native adapter does not implement it. | +| `webui_set_frameless()`, `webui_set_transparent()` | Native options and setters; `native.Window.dragRegion()` reports how pages declare drag areas. Windows transparency configures both host composition and WebView background; X11 requires RGBA/compositing. macOS transparency is explicitly unsupported, as upstream's native adapter does not implement it. | | `webui_show_wv()`, `webui_set_close_handler_wv()` | `native.Window.open()` plus `setCloseHandler()`. User/JavaScript close can be vetoed before destroying the page; `native.Window.close()` force-closes. | | WebView page-title tracking (no public upstream function) | `native.Options.follow_page_title` (default `true`), `native.Window.setFollowPageTitle()`, `setTitle()`, and `title()`. | | `webui_get_hwnd()`, `webui_win32_get_hwnd()` | `native.Window.handle()` returns a borrowed tagged Cocoa/Gtk/Win32 handle, invalid after close/deinit. | @@ -565,9 +565,9 @@ This completes `webui_set_runtime()`. This implements `webui_show_wv()`, `webui_set_close_handler_wv()`, and native handles. A separate ABI/ownership review was performed for every platform; the public API stays Zig-native, and runtime verification remains mandatory. -Page titles drive the host title as upstream does. This does not yet cover -upstream native drag/edge resize or GTK navigation-policy integration; see the -reopened semantic gaps. +Page titles drive the host title, and frameless windows drag and edge-resize, +as upstream does. This does not yet cover GTK navigation-policy integration; +see the reopened semantic gaps. ### Parity closure (reopened) From 3ad1b314fa959acacdb982c3a110e9b0340f8ef3 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 23:02:22 +0800 Subject: [PATCH 30/36] docs(native): move the Handle doc comment back to Handle --- src/native/types.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/native/types.zig b/src/native/types.zig index 9b4f293..b37763a 100644 --- a/src/native/types.zig +++ b/src/native/types.zig @@ -10,7 +10,6 @@ pub const Geometry = struct { size: Size, }; -/// Borrowed native window, invalid after native close or owner destruction. /// How pages declare areas that move the host window. pub const DragRegion = enum { /// Elements whose computed `--webui-app-region` is `drag` (WebKitGTK). @@ -23,6 +22,7 @@ pub const DragRegion = enum { none, }; +/// Borrowed native window, invalid after native close or owner destruction. pub const Handle = union(enum) { cocoa: *anyopaque, gtk: *anyopaque, From bb490216a5dded53b5bc36cebaf6f5722241e713 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 23:21:03 +0800 Subject: [PATCH 31/36] feat(native): decide page navigations through an engine-level handler Add NavigationRequest, NavigationKind, and NavigationHandler with Options.navigation_handler and Window.setNavigationHandler. The facade wraps the handler in a callback scope that rejects reentrant deinit. WebKitGTK answers decide-policy like upstream's policy handler, independent of any bridge connection: the host's own loads are exempt, every redirect hop is reported, and an unreadable request is cancelled. --- src/native.zig | 62 ++++++++++++++++++++++++++++++++++++++++++++ src/native/linux.zig | 60 ++++++++++++++++++++++++++++++++++++++++-- src/native/types.zig | 29 +++++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/src/native.zig b/src/native.zig index 3424829..7c7a5ee 100644 --- a/src/native.zig +++ b/src/native.zig @@ -13,6 +13,9 @@ pub const Geometry = types.Geometry; pub const Handle = types.Handle; pub const DragRegion = types.DragRegion; pub const CloseHandler = types.CloseHandler; +pub const NavigationHandler = types.NavigationHandler; +pub const NavigationRequest = types.NavigationRequest; +pub const NavigationKind = types.NavigationKind; const supported = switch (builtin.os.tag) { .macos, .linux, .windows => true, @@ -51,6 +54,8 @@ const State = struct { callback_depth: usize = 0, close_handler: ?CloseHandler, user_data: ?*anyopaque, + navigation_handler: ?NavigationHandler = null, + navigation_user_data: ?*anyopaque = null, minimum_size: ?Size = null, fn markClosed(self: *State) void { @@ -94,10 +99,13 @@ pub const Window = struct { .tasks = tasks, .close_handler = options.close_handler, .user_data = options.user_data, + .navigation_handler = options.navigation_handler, + .navigation_user_data = options.user_data, .minimum_size = options.minimum_size, }; var backend_options = options; backend_options.close_handler = closeRequested; + backend_options.navigation_handler = navigationRequested; backend_options.user_data = state; state.backend = try Backend.create(gpa, io, terminated, backend_options); return .{ .state = state }; @@ -312,6 +320,14 @@ pub const Window = struct { self.state.close_handler = handler; self.state.user_data = user_data; } + + /// Replace the navigation handler and its user data; null allows every + /// navigation. The close handler keeps its own user data. + pub fn setNavigationHandler(self: Window, handler: ?NavigationHandler, user_data: ?*anyopaque) !void { + try self.checkOpen(); + self.state.navigation_handler = handler; + self.state.navigation_user_data = user_data; + } }; fn closeRequested(user_data: ?*anyopaque) bool { @@ -323,6 +339,14 @@ fn closeRequested(user_data: ?*anyopaque) bool { return allowed; } +fn navigationRequested(user_data: ?*anyopaque, request: NavigationRequest) bool { + const state: *State = @ptrCast(@alignCast(user_data.?)); + const handler = state.navigation_handler orelse return true; + state.callback_depth += 1; + defer state.callback_depth -= 1; + return handler(state.navigation_user_data, request); +} + fn validateUrl(value: []const u8) !void { try types.validateText(value); const parsed = std.Uri.parse(value) catch return error.InvalidUrl; @@ -339,3 +363,41 @@ test "native navigation only accepts valid HTTP origins" { try std.testing.expectError(error.InvalidUrl, validateUrl("file:///tmp/page.html")); try std.testing.expectError(error.InvalidNativeText, validateUrl("https://example.com/\x00hidden")); } + +test "navigation requests reach the handler with its own user data inside a callback scope" { + const Probe = struct { + state: *State, + seen: usize = 0, + depth: usize = 0, + kind: NavigationKind = .other, + + fn decide(data: ?*anyopaque, request: NavigationRequest) bool { + const probe: *@This() = @ptrCast(@alignCast(data.?)); + probe.seen += 1; + probe.depth = probe.state.callback_depth; + probe.kind = request.kind; + return !std.mem.endsWith(u8, request.url, "/blocked"); + } + }; + var state: State = .{ + .gpa = std.testing.allocator, + .io = std.testing.io, + .owner = std.Thread.getCurrentId(), + .backend = undefined, + .tasks = &.{}, + .close_handler = null, + .user_data = null, + }; + // Without a handler every navigation proceeds. + try std.testing.expect(navigationRequested(&state, .{ .url = "http://127.0.0.1/blocked", .kind = .link })); + var probe: Probe = .{ .state = &state }; + state.navigation_handler = Probe.decide; + state.navigation_user_data = &probe; + try std.testing.expect(navigationRequested(&state, .{ .url = "http://127.0.0.1/next", .kind = .form_submission })); + try std.testing.expect(!navigationRequested(&state, .{ .url = "http://127.0.0.1/blocked", .kind = .back_forward })); + try std.testing.expectEqual(@as(usize, 2), probe.seen); + // Deinit is rejected while the handler runs, and the scope ends after it. + try std.testing.expectEqual(@as(usize, 1), probe.depth); + try std.testing.expectEqual(@as(usize, 0), state.callback_depth); + try std.testing.expectEqual(NavigationKind.back_forward, probe.kind); +} diff --git a/src/native/linux.zig b/src/native/linux.zig index 8ec47e7..b6e18ef 100644 --- a/src/native/linux.zig +++ b/src/native/linux.zig @@ -73,6 +73,18 @@ const resize_border = 6; // GdkWindowEdge values; corners are tested before sides. const Edge = enum(c_int) { north_west = 0, north = 1, north_east = 2, west = 3, east = 4, south_west = 5, south = 6, south_east = 7 }; const cursor_names = [_][:0]const u8{ "ns-resize", "ew-resize", "nwse-resize", "nesw-resize" }; +/// WebKitNavigationType to the portable kind. +fn navigationKind(value: c_int) types.NavigationKind { + return switch (value) { + 0 => .link, + 1 => .form_submission, + 2 => .back_forward, + 3 => .reload, + 4 => .form_resubmission, + else => .other, + }; +} + /// Resize edge under a WebView-relative point, or null for the interior. fn hitEdge(width: c_int, height: c_int, x: f64, y: f64) ?Edge { const right_band: f64 = @floatFromInt(width - resize_border); @@ -205,6 +217,12 @@ const Api = struct { webkit_user_script_new: *const fn ([*:0]const u8, c_int, c_int, ?[*:null]const ?[*:0]const u8, ?[*:null]const ?[*:0]const u8) callconv(.c) ?Object, webkit_user_script_unref: *const fn (Object) callconv(.c) void, webkit_javascript_result_get_js_value: *const fn (Object) callconv(.c) ?Object, + webkit_navigation_policy_decision_get_navigation_action: *const fn (Object) callconv(.c) ?Object, + webkit_navigation_action_get_navigation_type: *const fn (Object) callconv(.c) c_int, + webkit_navigation_action_get_request: *const fn (Object) callconv(.c) ?Object, + webkit_uri_request_get_uri: *const fn (Object) callconv(.c) ?[*:0]const u8, + webkit_policy_decision_use: *const fn (Object) callconv(.c) void, + webkit_policy_decision_ignore: *const fn (Object) callconv(.c) void, jsc_value_is_boolean: *const fn (Object) callconv(.c) c_int, jsc_value_to_boolean: *const fn (Object) callconv(.c) c_int, @@ -257,13 +275,16 @@ pub const Backend = struct { message_signals: [message_channels.len]c_ulong = @splat(0), registered_channels: usize = 0, window_signals: [3]c_ulong = .{ 0, 0, 0 }, - view_signals: [4]c_ulong = @splat(0), + view_signals: [5]c_ulong = @splat(0), cursors: [cursor_names.len]?Object = @splat(null), edge_cursor_shown: bool = false, frameless: bool = false, resizable: bool = true, close_handler: ?types.CloseHandler, + navigation_handler: ?types.NavigationHandler, user_data: ?*anyopaque, + // Set before each host load so its own policy decision is not reported. + host_navigation: bool = false, closed: bool = false, close_requested: bool = false, next_pending_close: ?*Backend = null, @@ -290,7 +311,7 @@ pub const Backend = struct { if (api.gtk_init_check(null, null) == 0) return error.NativeDisplayUnavailable; const self = try gpa.create(Backend); errdefer gpa.destroy(self); - self.* = .{ .gpa = gpa, .gtk = gtk, .webkit = webkit, .api = api, .close_handler = options.close_handler, .user_data = options.user_data, .follow_page_title = options.follow_page_title }; + self.* = .{ .gpa = gpa, .gtk = gtk, .webkit = webkit, .api = api, .close_handler = options.close_handler, .navigation_handler = options.navigation_handler, .user_data = options.user_data, .follow_page_title = options.follow_page_title }; errdefer self.releaseObjects(); const window = api.gtk_window_new(0) orelse return error.NativeInitializationFailed; @@ -358,6 +379,7 @@ pub const Backend = struct { api.gtk_widget_add_events(view, 4 | 256); // GDK_POINTER_MOTION_MASK | GDK_BUTTON_PRESS_MASK self.view_signals[2] = try self.connect(view, "button-press-event", @ptrCast(&buttonPressed)); self.view_signals[3] = try self.connect(view, "motion-notify-event", @ptrCast(&pointerMoved)); + self.view_signals[4] = try self.connect(view, "decide-policy", @ptrCast(&decidePolicy)); // GTK/WebKit setters copy strings synchronously; no borrowed Options // slices or URL buffers are retained in this backend. @@ -372,6 +394,7 @@ pub const Backend = struct { if (options.position) |position| try self.setPosition(position); if (options.center) try self.center(); if (options.kiosk) try self.setKiosk(true); + self.host_navigation = true; api.webkit_web_view_load_uri(view, url); if (!options.hidden) api.gtk_widget_show_all(window); return self; @@ -492,6 +515,7 @@ pub const Backend = struct { pub fn navigate(self: *Backend, url: [:0]const u8) !void { _ = try self.liveWindow(); self.process_failed = false; + self.host_navigation = true; self.api.webkit_web_view_load_uri(self.view.?, url); } @@ -679,6 +703,31 @@ pub const Backend = struct { return 1; // Any Backend's pump destroys accepted requests after emission. } + /// Like upstream's WebKitGTK policy handler, decide page navigations + /// in the engine, independent of any bridge connection. + fn decidePolicy(_: Object, decision: Object, decision_type: c_int, data: ?Object) callconv(.c) c_int { + const self: *Backend = @ptrCast(@alignCast(data.?)); + if (decision_type != 0) return 0; // WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION + const action = self.api.webkit_navigation_policy_decision_get_navigation_action(decision) orelse return 0; + // Each server redirect hop is a new request; WKWebView reports hops + // the same way and has no public redirect flag to filter them. + if (self.host_navigation) { + self.host_navigation = false; + return 0; + } + const handler = self.navigation_handler orelse return 0; + if (self.closed or self.close_requested) return 0; + // A navigation the handler cannot be asked about is cancelled. + const request = self.api.webkit_navigation_action_get_request(action); + const uri = if (request) |value| self.api.webkit_uri_request_get_uri(value) else null; + const allowed = if (uri) |value| handler(self.user_data, .{ + .url = std.mem.sliceTo(value, 0), + .kind = navigationKind(self.api.webkit_navigation_action_get_navigation_type(action)), + }) else false; + if (allowed) self.api.webkit_policy_decision_use(decision) else self.api.webkit_policy_decision_ignore(decision); + return 1; + } + fn scriptMessage(_: Object, result: Object, data: ?Object) callconv(.c) void { const self: *Backend = @ptrCast(@alignCast(data.?)); const value = self.api.webkit_javascript_result_get_js_value(result) orelse return; @@ -800,3 +849,10 @@ test "frameless resize hit-testing prefers corners within the edge band" { try std.testing.expectEqual(Edge.north_west, hitEdge(4, 4, 1, 1).?); try std.testing.expectEqual(Edge.north_west, hitEdge(0, 0, 0, 0).?); } + +test "WebKitGTK navigation types map to portable kinds" { + const expected = [_]types.NavigationKind{ .link, .form_submission, .back_forward, .reload, .form_resubmission, .other }; + for (expected, 0..) |kind, value| try std.testing.expectEqual(kind, navigationKind(@intCast(value))); + try std.testing.expectEqual(types.NavigationKind.other, navigationKind(-1)); + try std.testing.expectEqual(types.NavigationKind.other, navigationKind(99)); +} diff --git a/src/native/types.zig b/src/native/types.zig index b37763a..91e7558 100644 --- a/src/native/types.zig +++ b/src/native/types.zig @@ -33,6 +33,31 @@ pub const Handle = union(enum) { /// Programmatic Window.close() bypasses this veto. pub const CloseHandler = *const fn (?*anyopaque) bool; +/// What started a page navigation, as far as the engine reports it. +/// WebView2 distinguishes only `reload`, `back_forward`, and `other`. +pub const NavigationKind = enum { + link, + form_submission, + back_forward, + reload, + form_resubmission, + other, +}; + +/// A page-initiated navigation awaiting a decision. +pub const NavigationRequest = struct { + /// Target URL as reported by the engine, borrowed for the handler call. + url: []const u8, + kind: NavigationKind, +}; + +/// Called on the UI thread before a page or frame navigates. Return true to +/// let it proceed, false to cancel it and keep the current page. The host's +/// own requests (the initial load and `Window.navigate`) are not reported. +/// Every server redirect hop is reported as its own request, so a handler +/// can also stop a redirect to another origin. +pub const NavigationHandler = *const fn (?*anyopaque, NavigationRequest) bool; + pub const Options = struct { /// Initial host title, shown until the page reports a non-empty title. title: []const u8 = "WebUI", @@ -51,6 +76,10 @@ pub const Options = struct { profile_directory: ?[]const u8 = null, webview2_loader: ?[]const u8 = null, close_handler: ?CloseHandler = null, + /// Engine-level navigation interception, like upstream's WebKitGTK + /// policy handler; it also sees navigations the browser bridge cannot. + navigation_handler: ?NavigationHandler = null, + /// Passed to both `close_handler` and `navigation_handler`. user_data: ?*anyopaque = null, max_pending_tasks: usize = 64, From cbc7ebddeed7d72764f4c54514eee594b7f95c04 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 23:21:03 +0800 Subject: [PATCH 32/36] feat(native): decide WKWebView navigations through the handler Make the delegate the navigation delegate and answer decidePolicyForNavigationAction exactly once on every path. The host flag is consumed only by a main-frame decision, and server redirect hops reach the handler because WKWebView exposes no public redirect flag. --- src/native/macos.zig | 69 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/src/native/macos.zig b/src/native/macos.zig index 2d16557..10ebd4e 100644 --- a/src/native/macos.zig +++ b/src/native/macos.zig @@ -128,7 +128,8 @@ fn registerClasses() !void { !truth(class_addMethod(class, sel_registerName("windowWillClose:"), @ptrCast(&windowWillClose), "v@:@")) or !truth(class_addMethod(class, sel_registerName("webViewDidClose:"), @ptrCast(&webViewDidClose), "v@:@")) or !truth(class_addMethod(class, sel_registerName("userContentController:didReceiveScriptMessage:"), @ptrCast(&didReceiveScriptMessage), "v@:@@")) or - !truth(class_addMethod(class, sel_registerName("observeValueForKeyPath:ofObject:change:context:"), @ptrCast(&observeValue), "v@:@@@^v"))) + !truth(class_addMethod(class, sel_registerName("observeValueForKeyPath:ofObject:change:context:"), @ptrCast(&observeValue), "v@:@@@^v")) or + !truth(class_addMethod(class, sel_registerName("webView:decidePolicyForNavigationAction:decisionHandler:"), @ptrCast(&decidePolicy), "v@:@@@?"))) return error.NativeInitializationFailed; objc_registerClassPair(class); delegate_class = class; @@ -196,6 +197,35 @@ fn didReceiveScriptMessage(delegate: Id, _: Sel, controller: Id, message: Id) ca // per-State callback-depth guard also rejects deinit from the user handler. } +/// Leading fields of the Clang block ABI; WebKit passes a stack or heap +/// block that is only invoked, never copied or retained here. +const DecisionBlock = extern struct { + isa: ?*anyopaque, + flags: c_int, + reserved: c_int, + invoke: *const fn (*DecisionBlock, isize) callconv(.c) void, +}; +fn decidePolicy(delegate: Id, _: Sel, webview: Id, action: Id, decision: *DecisionBlock) callconv(.c) void { + // WebKit requires exactly one decision per request, on every path. + const allow = if (context(delegate)) |self| self.allowNavigation(webview, action) else true; + decision.invoke(decision, @intFromBool(allow)); // WKNavigationActionPolicyCancel = 0, Allow = 1 +} +fn isMainFrame(action: Id) bool { + const frame = send0(Id, action, "targetFrame") orelse return false; + return truth(send0(ObjcBool, frame, "isMainFrame")); +} +/// WKNavigationType to the portable kind. +fn navigationKind(value: isize) types.NavigationKind { + return switch (value) { + 0 => .link, + 1 => .form_submission, + 2 => .back_forward, + 3 => .reload, + 4 => .form_resubmission, + else => .other, + }; +} + pub const Backend = struct { gpa: std.mem.Allocator, // +1 ownership: window, webview, delegate, content controller and message @@ -215,7 +245,10 @@ pub const Backend = struct { closed: bool = false, in_close_handler: bool = false, close_handler: ?types.CloseHandler, + navigation_handler: ?types.NavigationHandler, user_data: ?*anyopaque, + // Set before each host load so its own policy decision is not reported. + host_navigation: bool = false, resizable: bool, frameless: bool, kiosk: bool = false, @@ -255,6 +288,7 @@ pub const Backend = struct { .gpa = gpa, .app = app, .close_handler = options.close_handler, + .navigation_handler = options.navigation_handler, .user_data = options.user_data, .resizable = options.resizable, .frameless = options.frameless, @@ -293,6 +327,7 @@ pub const Backend = struct { send1(void, configuration, "setUserContentController:", Id, self.content_controller); self.webview = send2(Id, send0(Id, objc_getClass("WKWebView"), "alloc"), "initWithFrame:configuration:", Rect, frame, Id, configuration) orelse return error.NativeInitializationFailed; send1(void, self.webview, "setUIDelegate:", Id, self.delegate); + send1(void, self.webview, "setNavigationDelegate:", Id, self.delegate); send1(void, self.webview, "setAutoresizingMask:", usize, 2 | 16); send1(void, self.window, "setContentView:", Id, self.webview); // WKWebView's title is KVO-compliant and also reports document.title @@ -338,6 +373,7 @@ pub const Backend = struct { send0(void, self.content_controller, "removeAllUserScripts"); send1(void, self.window, "setDelegate:", Id, null); send1(void, self.webview, "setUIDelegate:", Id, null); + send1(void, self.webview, "setNavigationDelegate:", Id, null); send0(void, self.webview, "stopLoading"); if (self.window != null and !self.closed) send0(void, self.window, "close"); send1(void, self.window, "setContentView:", Id, null); @@ -455,8 +491,32 @@ pub const Backend = struct { defer release(text); const address = send1(Id, objc_getClass("NSURL"), "URLWithString:", Id, text) orelse return error.InvalidNativeUrl; const request = send1(Id, objc_getClass("NSURLRequest"), "requestWithURL:", Id, address) orelse return error.InvalidNativeUrl; + self.host_navigation = true; + errdefer self.host_navigation = false; _ = send1(Id, self.webview, "loadRequest:", Id, request) orelse return error.NativeNavigationFailed; } + /// Like upstream's WebKitGTK policy handler, decide page and frame + /// navigations in the engine, independent of any bridge connection. + fn allowNavigation(self: *Backend, webview: Id, action: Id) bool { + if (self.closed or webview != self.webview) return true; + // Only a main-frame navigation can be the host's own request. + if (self.host_navigation and isMainFrame(action)) { + self.host_navigation = false; + return true; + } + const handler = self.navigation_handler orelse return true; + const autorelease_pool = pool(); + defer drain(autorelease_pool); + // A navigation the handler cannot be asked about is cancelled. + const request = send0(Id, action, "request") orelse return false; + const address = send0(Id, request, "URL") orelse return false; + const text = send0(Id, address, "absoluteString") orelse return false; + const bytes = send0(?[*:0]const u8, text, "UTF8String") orelse return false; + return handler(self.user_data, .{ + .url = std.mem.sliceTo(bytes, 0), + .kind = navigationKind(send0(isize, action, "navigationType")), + }); + } pub fn setSize(self: *Backend, value: types.Size) !void { try self.requireOpen(); if (self.kiosk) return error.UnsupportedNativeControl; @@ -630,3 +690,10 @@ fn dimension(value: f64) !u32 { return error.NativeGeometryOutOfRange; return @intFromFloat(rounded); } + +test "WKNavigationType values map to portable kinds" { + const expected = [_]types.NavigationKind{ .link, .form_submission, .back_forward, .reload, .form_resubmission }; + for (expected, 0..) |kind, value| try std.testing.expectEqual(kind, navigationKind(@intCast(value))); + try std.testing.expectEqual(types.NavigationKind.other, navigationKind(-1)); + try std.testing.expectEqual(types.NavigationKind.other, navigationKind(42)); +} From 3f404937be82e83cfb9e05d87311207e954059f8 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 23:21:03 +0800 Subject: [PATCH 33/36] feat(native): decide WebView2 navigations through the handler Subscribe NavigationStarting and FrameNavigationStarting and cancel through put_Cancel when the handler declines. Only the top-level event can consume the host flag. NavigationStartingEventArgs3 supplies reload and back/forward kinds when present, and an unreadable or invalid UTF-16 URL cancels. --- src/native/windows.zig | 92 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/native/windows.zig b/src/native/windows.zig index 51c5528..b7357bd 100644 --- a/src/native/windows.zig +++ b/src/native/windows.zig @@ -17,6 +17,8 @@ const iid_environment_callback = GUID.parse("{4e8a3389-c9d8-4bd2-b6b5-124fee6cc1 const iid_controller_callback = GUID.parse("{6c4819f3-c9b7-4260-8127-c9f5bde7f68c}"); const iid_close_callback = GUID.parse("{57213f19-00e6-49fa-8e07-898ea01ecbd2}"); // WebMessageReceived const iid_title_callback = GUID.parse("{f5f2b923-953e-4042-9f95-f3a118e1afd4}"); // DocumentTitleChanged +const iid_navigation_callback = GUID.parse("{9adbe429-f36d-432b-9ddc-f8881fbd76e3}"); // (Frame)NavigationStarting +const iid_navigation_args3 = GUID.parse("{ddffe494-4942-4bd2-ab73-35b8ff40e19f}"); // NavigationKind // Upper bound for environment, controller, and document-script creation. // Cold starts spawn the browser processes and user data folder; CI runners // take about 7 s for two windows and occasionally exceed 15 s. @@ -247,6 +249,8 @@ fn EventCallback(comptime iid: GUID, comptime handle: fn (*Backend, ?*Com) void) const CloseCallback = EventCallback(iid_close_callback, closeMessage); const TitleCallback = EventCallback(iid_title_callback, titleChanged); +const NavigationCallback = EventCallback(iid_navigation_callback, navigationStarting); +const FrameNavigationCallback = EventCallback(iid_navigation_callback, frameNavigationStarting); fn closeMessage(owner: *Backend, args: ?*Com) void { const message_args = args orelse return; @@ -266,6 +270,23 @@ fn titleChanged(owner: *Backend, _: ?*Com) void { owner.applyPageTitle(); } +fn navigationStarting(owner: *Backend, args: ?*Com) void { + owner.decideNavigation(args, true); +} + +fn frameNavigationStarting(owner: *Backend, args: ?*Com) void { + owner.decideNavigation(args, false); +} + +/// COREWEBVIEW2_NAVIGATION_KIND to the portable kind. +fn navigationKind(value: i32) types.NavigationKind { + return switch (value) { + 0 => .reload, + 1 => .back_forward, + else => .other, // NEW_DOCUMENT: links, forms, and scripts alike + }; +} + pub const Backend = struct { gpa: std.mem.Allocator, loader: ?*Loader = null, @@ -280,9 +301,16 @@ pub const Backend = struct { close_token: ?Token = null, title_callback: ?*TitleCallback = null, title_token: ?Token = null, + navigation_callback: ?*NavigationCallback = null, + navigation_token: ?Token = null, + frame_navigation_callback: ?*FrameNavigationCallback = null, + frame_navigation_token: ?Token = null, follow_page_title: bool, close_handler: ?types.CloseHandler, + navigation_handler: ?types.NavigationHandler, user_data: ?*anyopaque, + // Set before each host Navigate so its own NavigationStarting is not reported. + host_navigation: bool = false, close_requested: bool = false, closed: bool = false, event_error: ?anyerror = null, @@ -302,6 +330,7 @@ pub const Backend = struct { self.* = .{ .gpa = gpa, .close_handler = options.close_handler, + .navigation_handler = options.navigation_handler, .user_data = options.user_data, .minimum = options.minimum_size, .resizable = options.resizable, @@ -389,6 +418,14 @@ pub const Backend = struct { var title_token: Token = .{}; try check(webview.method(46, *const fn (*Com, *TitleCallback, *Token) callconv(.winapi) HRESULT)(webview, self.title_callback.?, &title_token)); self.title_token = title_token; + self.navigation_callback = try NavigationCallback.create(self); + var navigation_token: Token = .{}; + try check(webview.method(7, *const fn (*Com, *NavigationCallback, *Token) callconv(.winapi) HRESULT)(webview, self.navigation_callback.?, &navigation_token)); + self.navigation_token = navigation_token; + self.frame_navigation_callback = try FrameNavigationCallback.create(self); + var frame_navigation_token: Token = .{}; + try check(webview.method(17, *const fn (*Com, *FrameNavigationCallback, *Token) callconv(.winapi) HRESULT)(webview, self.frame_navigation_callback.?, &frame_navigation_token)); + self.frame_navigation_token = frame_navigation_token; // WindowCloseRequested is too late to guarantee veto or repeat requests; // Chromium can also refuse native window.close after history navigation. // Replace it before page scripts run, without marking the page closed. @@ -452,6 +489,8 @@ pub const Backend = struct { fn releaseWebView(self: *Backend) void { if (self.close_callback) |callback| callback.owner = null; if (self.title_callback) |callback| callback.owner = null; + if (self.navigation_callback) |callback| callback.owner = null; + if (self.frame_navigation_callback) |callback| callback.owner = null; if (self.webview) |webview| { if (self.close_token) |token| { _ = webview.method(35, *const fn (*Com, Token) callconv(.winapi) HRESULT)(webview, token); @@ -461,6 +500,14 @@ pub const Backend = struct { _ = webview.method(47, *const fn (*Com, Token) callconv(.winapi) HRESULT)(webview, token); self.title_token = null; } + if (self.navigation_token) |token| { + _ = webview.method(8, *const fn (*Com, Token) callconv(.winapi) HRESULT)(webview, token); + self.navigation_token = null; + } + if (self.frame_navigation_token) |token| { + _ = webview.method(18, *const fn (*Com, Token) callconv(.winapi) HRESULT)(webview, token); + self.frame_navigation_token = null; + } } if (self.controller) |controller| _ = controller.closeController(); if (self.webview) |webview| webview.release(); @@ -471,6 +518,10 @@ pub const Backend = struct { self.close_callback = null; if (self.title_callback) |callback| _ = TitleCallback.release(callback); self.title_callback = null; + if (self.navigation_callback) |callback| _ = NavigationCallback.release(callback); + self.navigation_callback = null; + if (self.frame_navigation_callback) |callback| _ = FrameNavigationCallback.release(callback); + self.frame_navigation_callback = null; } pub fn pump(self: *Backend) !bool { @@ -581,8 +632,42 @@ pub const Backend = struct { const webview = self.webview orelse return error.NativeWindowClosed; const text = try std.unicode.utf8ToUtf16LeAllocZ(self.gpa, url); defer self.gpa.free(text); + self.host_navigation = true; + errdefer self.host_navigation = false; try check(webview.method(5, *const fn (*Com, [*:0]const u16) callconv(.winapi) HRESULT)(webview, text.ptr)); } + /// Like upstream's WebKitGTK policy handler, decide page and frame + /// navigations in the engine, independent of any bridge connection. + fn decideNavigation(self: *Backend, args: ?*Com, top_level: bool) void { + const event = args orelse return; + // Only the top-level navigation can be the host's own request. + if (top_level and self.host_navigation) { + self.host_navigation = false; + return; + } + const handler = self.navigation_handler orelse return; + if (self.close_requested) return; + // A navigation the handler cannot be asked about is cancelled. + if (!self.askNavigation(event, handler)) + _ = event.method(8, *const fn (*Com, i32) callconv(.winapi) HRESULT)(event, 1); // put_Cancel + } + fn askNavigation(self: *Backend, event: *Com, handler: types.NavigationHandler) bool { + var uri: ?[*:0]u16 = null; + if (event.method(3, *const fn (*Com, *?[*:0]u16) callconv(.winapi) HRESULT)(event, &uri) < 0) return false; + const wide = uri orelse return false; + defer CoTaskMemFree(wide); + const url = std.unicode.utf16LeToUtf8Alloc(self.gpa, std.mem.sliceTo(wide, 0)) catch return false; + defer self.gpa.free(url); + var kind: i32 = 2; // NEW_DOCUMENT when Args3 is unavailable + var args3: ?*Com = null; + if (event.method(0, *const fn (*Com, *const GUID, *?*Com) callconv(.winapi) HRESULT)(event, &iid_navigation_args3, &args3) >= 0) { + if (args3) |value| { + defer value.release(); + if (value.method(12, *const fn (*Com, *i32) callconv(.winapi) HRESULT)(value, &kind) < 0) kind = 2; + } + } + return handler(self.user_data, .{ .url = url, .kind = navigationKind(kind) }); + } pub fn setSize(self: *Backend, value: types.Size) !void { const hwnd = try self.window(); if (self.kiosk) return error.UnsupportedNativeOperation; @@ -891,3 +976,10 @@ extern "user32" fn SetForegroundWindow(HWND) callconv(.winapi) i32; extern "user32" fn SetFocus(HWND) callconv(.winapi) ?HWND; extern "user32" fn InvalidateRect(HWND, ?*const RECT, i32) callconv(.winapi) i32; extern "user32" fn FillRect(*anyopaque, *const RECT, *anyopaque) callconv(.winapi) i32; + +test "WebView2 navigation kinds map to portable kinds" { + try std.testing.expectEqual(types.NavigationKind.reload, navigationKind(0)); + try std.testing.expectEqual(types.NavigationKind.back_forward, navigationKind(1)); + try std.testing.expectEqual(types.NavigationKind.other, navigationKind(2)); + try std.testing.expectEqual(types.NavigationKind.other, navigationKind(-1)); +} From cefae007967983e9b936c040139e9eff0114c6a5 Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 23:21:03 +0800 Subject: [PATCH 34/36] test(native): exercise engine navigation decisions in the smoke Block a script navigation and a link click while the page stays alive, allow a favicon.ico navigation and require its 302 hop to favicon.svg to be reported, and confirm a host navigation back is not reported. --- examples/native/main.zig | 89 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/examples/native/main.zig b/examples/native/main.zig index d47334b..2689f5d 100644 --- a/examples/native/main.zig +++ b/examples/native/main.zig @@ -434,7 +434,87 @@ fn frameless(io: std.Io, view: webui.native.Window, page: *Page, require_input: return true; } -fn smoke(io: std.Io, first: *Page, second: *Page, require_input: bool) !void { +/// Records page navigations and cancels those whose URL mentions "blocked". +const NavigationLog = struct { + count: usize = 0, + kinds: [6]webui.native.NavigationKind = undefined, + urls: [6][256]u8 = undefined, + lengths: [6]usize = undefined, + + fn decide(data: ?*anyopaque, request: webui.native.NavigationRequest) bool { + const log: *NavigationLog = @ptrCast(@alignCast(data.?)); + if (log.count < log.kinds.len) { + const length = @min(request.url.len, log.urls[log.count].len); + @memcpy(log.urls[log.count][0..length], request.url[0..length]); + log.lengths[log.count] = length; + log.kinds[log.count] = request.kind; + } + log.count += 1; + return std.mem.indexOf(u8, request.url, "blocked") == null; + } + + fn expect(self: *const NavigationLog, index: usize, kind: webui.native.NavigationKind, suffix: []const u8) !void { + const url = self.urls[index][0..self.lengths[index]]; + if (self.kinds[index] != kind or !std.mem.endsWith(u8, url, suffix)) { + std.log.err("native navigation {d} was {s} {s}, expected {s} ...{s}", .{ index, @tagName(self.kinds[index]), url, @tagName(kind), suffix }); + return error.NativeNavigationMisreported; + } + } +}; + +fn pumpFor(io: std.Io, view: webui.native.Window, milliseconds: i64) !void { + const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .clock = .awake, .raw = .fromMilliseconds(milliseconds) }); + while (deadline.compare(.gt, .now(io, .awake))) { + if (!try view.poll()) return error.NativeClosedEarly; + try std.Io.sleep(io, .fromMilliseconds(10), .awake); + } +} + +fn awaitNavigations(io: std.Io, view: webui.native.Window, log: *const NavigationLog, count: usize) !void { + const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .clock = .awake, .raw = .fromSeconds(5) }); + while (log.count < count) { + if (!try view.poll()) return error.NativeClosedEarly; + if (deadline.compare(.lte, .now(io, .awake))) return error.NativeNavigationNotReported; + try std.Io.sleep(io, .fromMilliseconds(10), .awake); + } +} + +/// Engine-level navigation decisions, independent of the bridge. +fn navigation(io: std.Io, view: webui.native.Window, page: *Page, url: []const u8) !void { + var log: NavigationLog = .{}; + try view.setNavigationHandler(NavigationLog.decide, &log); + defer view.setNavigationHandler(null, null) catch {}; + // Stop the bridge's own interception so the engine sees each navigation. + try evaluate(io, view, page.window, "webui.allowNavigation(true); window.kept = 'kept'; location.assign('blocked-script'); return 'assigned'", "assigned"); + try awaitNavigations(io, view, &log, 1); + try evaluate(io, view, page.window, "const a = document.createElement('a'); a.href = 'blocked-link'; document.body.appendChild(a); a.click(); return 'clicked'", "clicked"); + try awaitNavigations(io, view, &log, 2); + try pumpFor(io, view, 300); + try evaluate(io, view, page.window, "return window.kept", "kept"); + try log.expect(0, .other, "/blocked-script"); + // WebView2 reports no link or form kinds. + try log.expect(1, if (builtin.os.tag == .windows) .other else .link, "/blocked-link"); + // favicon.ico answers 302 to favicon.svg; each hop is decided. + try evaluate(io, view, page.window, "setTimeout(() => location.assign('favicon.ico'), 50); return 'leaving'", "leaving"); + try awaitNavigations(io, view, &log, 4); + try pumpFor(io, view, 500); + try log.expect(2, .other, "/favicon.ico"); + try log.expect(3, .other, "/favicon.svg"); + // Host navigations are never reported. + page.ready.store(false, .release); + var buffer: [512]u8 = undefined; + try view.navigate(try std.fmt.bufPrint(&buffer, "{s}?host", .{url})); + try pumpUntil(io, view, page, null); + try evaluate(io, view, page.window, "return location.search", "?host"); + if (log.count != 4) { + std.log.err("native navigation reported {d} requests, expected 4", .{log.count}); + for (0..@min(log.count, log.kinds.len)) |index| + std.log.err(" {d}: {s} {s}", .{ index, @tagName(log.kinds[index]), log.urls[index][0..log.lengths[index]] }); + return error.NativeNavigationMisreported; + } +} + +fn smoke(io: std.Io, first: *Page, second: *Page, url: []const u8, require_input: bool) !void { const a = first.native.?; const b = second.native.?; try pumpUntil(io, a, first, null); @@ -528,10 +608,11 @@ fn smoke(io: std.Io, first: *Page, second: *Page, require_input: bool) !void { _ = try a.poll(); if (b.geometry()) |_| return error.NativeSecondaryCloseFailed else |err| if (err != error.NativeWindowClosed) return err; try evaluate(io, a, first.window, "return await webui.call('ready')", "Connected to Zig"); + try navigation(io, a, first, url); const interaction = if (try frameless(io, a, first, require_input)) "frameless drag+resize input" else "frameless configuration"; try a.close(); if (try a.poll()) return error.NativeForceCloseFailed; - std.debug.print("NATIVE SMOKE PASS: bridge, geometry, controls, dispatch, titles, veto, history, {s}, multiwindow close\n", .{interaction}); + std.debug.print("NATIVE SMOKE PASS: bridge, geometry, controls, dispatch, titles, veto, history, navigation, {s}, multiwindow close\n", .{interaction}); } pub fn main(init: std.process.Init) !void { @@ -582,7 +663,9 @@ pub fn main(init: std.process.Init) !void { var second_view = try webui.native.Window.open(init.gpa, init.io, second.window, &running, options); defer second_view.deinit() catch |err| std.log.err("native cleanup: {}", .{err}); second.native = second_view; - try smoke(init.io, &first, &second, require_input); + const url = try first.window.url(&running, init.gpa); + defer init.gpa.free(url); + try smoke(init.io, &first, &second, url, require_input); } else { try first_view.run(); } From 7c62c1f0fcb6a57771451432c41af2b33d3e588a Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 23:21:03 +0800 Subject: [PATCH 35/36] docs: document native navigation decisions and close the last semantic gap Describe the handler contract, redirect and frame reporting, per-engine kinds, and how to leave bridge-intercepted navigations to the engine. Move native navigation policy to the closed table and add its method mapping. --- README.md | 27 ++++++++++++++++++++++++--- docs/PURE_ZIG_REFACTOR.md | 9 +++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0a880ad..973982a 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ compile or link the upstream WebUI C library or CivetWeb. [Linsang](https://github.com/jinzhongjia/Linsang) provides HTTP and WebSocket support. -The core rewrite is substantial, but full upstream behavioral parity is not -complete. A fresh source audit found a remaining gap in GTK engine-level -navigation integration. See the +The semantic gaps found by the latest upstream source audit are closed. The +remaining listed difference is the default presentation policy (F5, context +menu, DevTools), kept as an intentional UI choice. See the [open semantic gaps](docs/PURE_ZIG_REFACTOR.md#open-semantic-gaps) and the [source comparison](docs/UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). @@ -605,6 +605,27 @@ vetoed page and its bridge alive, including after navigation history changes. services native events and accepted close requests for other windows on its UI thread. +`Options.navigation_handler` or `setNavigationHandler(handler, user_data)` +decides page navigations in the engine, like upstream's WebKitGTK policy +handler, so it also sees navigations the browser bridge cannot intercept or that +happen without a live bridge. The handler runs on the UI thread with a borrowed +`NavigationRequest { url, kind }` and returns `false` to cancel and keep the +current page. Without a handler every navigation proceeds. + +- The host's own requests (the initial load and `navigate()`) are not reported. +- Navigations in child frames are reported too. +- Every server redirect hop is reported as its own request, so a handler can + stop a redirect to another origin. WKWebView has no public redirect flag, so + this is the only behavior that is consistent across engines. +- WebKitGTK and WKWebView report `link`, `form_submission`, `back_forward`, + `reload`, `form_resubmission`, or `other`. WebView2 reports only `reload`, + `back_forward`, and `other`. +- A navigation whose URL cannot be read is cancelled. + +With an `onEvent` handler installed, the bridge intercepts link clicks and +script navigations before the engine sees them; call `webui.allowNavigation(true)` +in the page to leave those decisions to the native handler. + Platform prerequisites and explicit limits: - **macOS:** link `objc`, Foundation, AppKit, and WebKit. Public WKWebView APIs do diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index 4ee92a8..00106f6 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -49,7 +49,6 @@ is in [the source audit](UPSTREAM_LOGIC_AUDIT.md#2026-09-12-source-rescan). | Area | Remaining behavior, not covered by existing API mappings | |---|---| -| Native page integration | No GTK engine-level navigation-policy interception independent of a live bridge. | | Default presentation | F5/context-menu/DevTools policy differences are intentional UI-policy candidates, not proof of missing protocol support. | Closed after the rescan, each with focused tests in the same change: @@ -65,6 +64,7 @@ Closed after the rescan, each with focused tests in the same change: | Browser discovery | Windows tells Chrome from Chromium among `PATH` and `App Paths` `chrome.exe` candidates by Google's `initial_preferences`/`master_preferences` files, like upstream, so registered Chromium is found and never mistaken for Chrome. macOS adds `~/Applications` and a side-effect-free Spotlight bundle-identifier lookup in place of upstream's Finder-revealing `open -R -a`. Tests: `Windows Chrome and Chromium installs are told apart by Google's installer files`, `macOS bundles resolve from Spotlight output`. | | Native page titles | Like upstream, each non-empty page title replaces the host title: GTK `notify::title`, WKWebView `title` KVO (which, unlike upstream's `didFinishNavigation`, also reports later `document.title` changes), and WebView2 `DocumentTitleChanged`. `Options.title` is the initial title and `setTitle` applies at once until the next page title; an empty title keeps the host title, while WebView2 reports its own default for untitled documents. `follow_page_title = false` or `setFollowPageTitle(false)` keeps titles host-controlled; re-enabling applies the current page title. `title()` reads the host title. Test: native smoke titles step on all three platforms. | | Native interaction | `native.Window.dragRegion()` reports each backend's model. WebKitGTK mirrors upstream's `--webui-app-region` script on a dedicated message channel and starts a window-manager move only while the primary button is held; resizable frameless windows resize from upstream's 6 px edge band with resize cursors. WebView2 enables Settings9 non-client regions for CSS `app-region` (reporting `.none` without Settings9), and resizable frameless windows keep `WS_THICKFRAME`. Cocoa frameless windows are movable by background, also across style and kiosk changes. Tests: native smoke frameless step with real pointer input on Linux (xdotool) and Windows (`mouse_event`): drag moves the window, a right-edge drag widens it, and on GTK a forged request without a held button does not move it; macOS reads back `isMovableByWindowBackground` because input synthesis needs Accessibility permission. `frameless resize hit-testing prefers corners within the edge band`. | +| Native navigation policy | `native.Options.navigation_handler` / `Window.setNavigationHandler` decide page and frame navigations in the engine, like upstream's WebKitGTK `decide-policy` handler but on every backend: GTK `decide-policy`, WKWebView `decidePolicyForNavigationAction`, WebView2 `NavigationStarting` and `FrameNavigationStarting`. The host's own initial load and `navigate()` are not reported. Each server redirect hop is reported, because WKWebView has no public redirect flag. A URL that cannot be read cancels the navigation. The native smoke blocks a script navigation and a link click, keeping the page alive. It checks that an allowed `favicon.ico` navigation reports its `302` hop to `favicon.svg`, and that a host navigation back is not reported. Removing the host exemption or the cancel, or (on GTK) ignoring redirects, makes the smoke fail. Tests: `navigation requests reach the handler with its own user data inside a callback scope` and per-backend kind mappings. | | Entry and custom routing | `Site.entry` redirects the root to a validated relative entry file, and handlers that decline a path (empty `404`) are probed for the entry name or `index.*` with `302` redirects, for both `.site` and `.custom`. Same tests as content composition. | Borrowed custom HTTP handlers can await work before returning through `std.Io`; @@ -374,6 +374,7 @@ external-browser launch flags. | `webui_set_resizable()`, `webui_set_minimum_size()` | `native.Options` and `native.Window.setResizable()` / `setMinimumSize()`. | | `webui_set_frameless()`, `webui_set_transparent()` | Native options and setters; `native.Window.dragRegion()` reports how pages declare drag areas. Windows transparency configures both host composition and WebView background; X11 requires RGBA/compositing. macOS transparency is explicitly unsupported, as upstream's native adapter does not implement it. | | `webui_show_wv()`, `webui_set_close_handler_wv()` | `native.Window.open()` plus `setCloseHandler()`. User/JavaScript close can be vetoed before destroying the page; `native.Window.close()` force-closes. | +| WebKitGTK `WEBUI_EVENT_NAVIGATION` (`decide-policy`) | `native.Options.navigation_handler` or `native.Window.setNavigationHandler()`, on every backend. Returning `false` cancels like upstream's ignored policy decision. The host's own loads are exempt, as with upstream's first navigation after show. Bridge-level navigation events stay `Event.kind == .navigation`. | | WebView page-title tracking (no public upstream function) | `native.Options.follow_page_title` (default `true`), `native.Window.setFollowPageTitle()`, `setTitle()`, and `title()`. | | `webui_get_hwnd()`, `webui_win32_get_hwnd()` | `native.Window.handle()` returns a borrowed tagged Cocoa/Gtk/Win32 handle, invalid after close/deinit. | @@ -565,9 +566,9 @@ This completes `webui_set_runtime()`. This implements `webui_show_wv()`, `webui_set_close_handler_wv()`, and native handles. A separate ABI/ownership review was performed for every platform; the public API stays Zig-native, and runtime verification remains mandatory. -Page titles drive the host title, and frameless windows drag and edge-resize, -as upstream does. This does not yet cover GTK navigation-policy integration; -see the reopened semantic gaps. +Page titles drive the host title, frameless windows drag and edge-resize, and +an optional engine-level navigation handler decides page navigations, as +upstream does. ### Parity closure (reopened) From 4fea75488b72704cff181233f3e600fd4e4d620f Mon Sep 17 00:00:00 2001 From: jinzhongjia Date: Tue, 6 Oct 2026 23:45:00 +0800 Subject: [PATCH 36/36] test(native): let window-manager state changes settle in the smoke Minimize, restore, maximize, kiosk, and visibility requests were issued with at most one pump between them. X11 window managers apply them asynchronously and GDK applies (un)maximize to a window it considers unmapped only locally, so a later request could race an earlier one and leave GTK's toplevel resize queue waiting forever; every later setSize was then ignored. Pump for a bounded settle period after each change. --- examples/native/main.zig | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/examples/native/main.zig b/examples/native/main.zig index 2689f5d..3da333e 100644 --- a/examples/native/main.zig +++ b/examples/native/main.zig @@ -514,6 +514,9 @@ fn navigation(io: std.Io, view: webui.native.Window, page: *Page, url: []const u } } +/// Time for a window manager to apply one state change. +const settle_ms = 250; + fn smoke(io: std.Io, first: *Page, second: *Page, url: []const u8, require_input: bool) !void { const a = first.native.?; const b = second.native.?; @@ -542,17 +545,26 @@ fn smoke(io: std.Io, first: *Page, second: *Page, url: []const u8, require_input try a.setFrameless(false); try a.setPosition(.{ .x = 32, .y = 48 }); try a.center(); + // X11 window managers apply state requests asynchronously, and GDK + // applies (un)maximize to a window it considers unmapped only locally. + // Let each change settle so a later request cannot race an earlier one + // and leave GTK waiting for a configure reply that never comes. try a.minimize(); - _ = try a.poll(); + try pumpFor(io, a, settle_ms); try a.restore(); + try pumpFor(io, a, settle_ms); try a.maximize(); - _ = try a.poll(); + try pumpFor(io, a, settle_ms); try a.restore(); + try pumpFor(io, a, settle_ms); try a.setKiosk(true); - _ = try a.poll(); + try pumpFor(io, a, settle_ms); try a.setKiosk(false); + try pumpFor(io, a, settle_ms); try a.setVisible(false); + try pumpFor(io, a, settle_ms); try a.setVisible(true); + try pumpFor(io, a, settle_ms); try a.focus(); _ = try a.handle(); if (builtin.os.tag == .macos) {