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/README.md b/README.md index 62cae55..973982a 100644 --- a/README.md +++ b/README.md @@ -9,10 +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 gaps in content composition, live window -creation/destruction, Firefox app profiles, and native drag/resize, title and -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). @@ -70,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; @@ -138,16 +138,49 @@ 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. +`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 @@ -160,9 +193,15 @@ 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 `/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` with an optional full executable path and additional argv. Chromium-family @@ -198,8 +237,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. @@ -213,16 +255,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 @@ -251,8 +305,40 @@ 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`). + +`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 @@ -290,6 +376,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 @@ -315,6 +407,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. @@ -465,6 +567,36 @@ 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 +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 @@ -473,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 @@ -486,7 +639,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. @@ -508,7 +661,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/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); diff --git a/docs/PURE_ZIG_REFACTOR.md b/docs/PURE_ZIG_REFACTOR.md index 399685e..00106f6 100644 --- a/docs/PURE_ZIG_REFACTOR.md +++ b/docs/PURE_ZIG_REFACTOR.md @@ -49,16 +49,23 @@ 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`. | -| 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. | -| 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: + +| 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`. | +| 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`. | +| 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`; there is no owned post-return HTTP reply handle. This is an explicit Zig task @@ -330,6 +337,8 @@ 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.window` | `Call.client.window()` and `Event.client.window()`. | | `Event.return*()` | `Call.reply*()` | | `window.run()` | `Window.eval()` | | `Event.runClient()` | `Call.client.eval()` | @@ -363,8 +372,10 @@ 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. | +| 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. | ### Browser Bridge APIs @@ -408,17 +419,17 @@ 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_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. | | `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. | | `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_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. | @@ -426,10 +437,10 @@ 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. 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()`, `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`. | @@ -437,7 +448,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()`. | @@ -448,7 +459,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. `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. | @@ -500,8 +511,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, profile and app-presentation behavior still has the Firefox, -Windows Chromium and macOS resolver gaps listed above. +Discovery covers registered Windows Chromium and macOS bundles outside the +standard application directories. ### Browser window controls @@ -511,8 +522,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`. @@ -552,8 +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. -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, frameless windows drag and edge-resize, and +an optional engine-level navigation handler decides page navigations, as +upstream does. ### Parity closure (reopened) diff --git a/examples/native/main.zig b/examples/native/main.zig index 16a32c1..3da333e 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, @@ -149,7 +185,339 @@ 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; +} + +/// 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; + } +} + +/// 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.?; try pumpUntil(io, a, first, null); @@ -177,17 +545,26 @@ fn smoke(io: std.Io, first: *Page, second: *Page) !void { 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) { @@ -210,6 +587,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; @@ -224,20 +620,25 @@ 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"); + 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, veto, history, multiwindow close\n", .{}); + 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 { 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]; @@ -269,10 +670,14 @@ 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; - try smoke(init.io, &first, &second); + 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(); } 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 9927534..aac02f7 100644 --- a/src/app.zig +++ b/src/app.zig @@ -17,7 +17,35 @@ 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 = ""; +/// 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 { @@ -36,9 +64,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 @@ -123,8 +155,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, @@ -146,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 { @@ -472,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) { @@ -484,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) }, }; } @@ -493,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(); } }; @@ -582,9 +712,31 @@ const WindowState = struct { /// Whether registry updates should notify active browser peers. running: std.atomic.Value(bool) = .init(false), + /// 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. + /// 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, @@ -597,17 +749,31 @@ 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, 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); 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); @@ -630,6 +796,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); @@ -638,6 +805,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; @@ -651,16 +820,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( @@ -958,9 +1120,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 +1240,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(); @@ -1041,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; @@ -1073,10 +1308,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, @@ -1094,18 +1333,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); } @@ -1148,18 +1389,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); } @@ -1182,6 +1427,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 = &.{}, }; @@ -1204,18 +1452,41 @@ 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); } 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); @@ -1227,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( @@ -1559,6 +1830,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, @@ -1570,6 +1848,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]; @@ -1644,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); @@ -1717,7 +2005,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, @@ -1802,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, @@ -1934,12 +2263,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, @@ -1986,6 +2333,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 +2349,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, @@ -2245,14 +2594,24 @@ 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, - monitor_tasks: std.Io.Group = .init, 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, @@ -2261,19 +2620,47 @@ 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, + }); + // The record owns a reference until onClose releases it. + window.retain(); _ = 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); @@ -2294,7 +2681,9 @@ pub const App = struct { } } - fn removeUpgrade(self: *App, io: std.Io, key: usize) ?*WindowState { + /// 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); for (self.upgrades.items, 0..) |upgrade, index| { @@ -2304,7 +2693,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; @@ -2320,6 +2709,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 = .{}, @@ -2370,18 +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.monitor_tasks.token.load(.acquire) == 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, @@ -2449,10 +2850,85 @@ 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 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); + } + 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 { if (self.started) return error.AlreadyStarted; if (self.windows.items.len == 0) return error.NoWindow; @@ -2480,28 +2956,20 @@ 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; - } - } - self.ever_connected.store(false, .release); + 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| { - window.close_requested.store(false, .release); - window.cookie_issued.store(false, .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, @@ -2522,16 +2990,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; - if (self.options.folder_monitor_interval) |interval| { - for (self.windows.items) |window| - self.monitor_tasks.async(io, monitorDirectory, .{ - window, - io, - interval, - }); - } + 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 }; } @@ -2540,6 +3005,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, @@ -2581,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| { @@ -2612,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.?; } @@ -2654,6 +3122,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); @@ -2664,41 +3145,59 @@ 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; } - fn closeRequested(self: *const App) bool { - for (self.windows.items) |window| - if (window.close_requested.load(.acquire)) 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 {}; + } } }; @@ -2710,9 +3209,11 @@ pub const Running = struct { pub fn stop(self: *Running) !void { if (self.stopped) return; try self.inner.stop(); - for (self.app.windows.items) |window| + self.app.closeRegistration(self.inner.io); + 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); @@ -2727,31 +3228,57 @@ 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; + 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. + /// + /// 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) { + while (!app.exit_requested.load(.acquire)) { + const now = std.Io.Clock.Timestamp.now(io, .awake); + 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); - if (!self.app.ever_connected.load(.acquire)) continue; - if (self.app.hasClients(io)) { - deadline = null; - continue; - } - 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, - }); - } } try self.stop(); } @@ -2835,22 +3362,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( @@ -3123,7 +3635,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] != '/') @@ -3131,7 +3644,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 ..], }; @@ -3157,10 +3670,18 @@ 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 + std.mem.eql(u8, request.path, "/favicon.svg")) + return writeDefaultFavicon(response, request.path[1..]); 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; @@ -3170,7 +3691,7 @@ 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); if (std.mem.eql(u8, resolved.resource, "_webui_ws_connect")) { @@ -3180,6 +3701,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 @@ -3199,6 +3726,20 @@ 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, + // Sites consult their handler first and fall back themselves. + .site => 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 @@ -3224,67 +3765,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, @@ -3292,15 +3783,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, @@ -3320,13 +3992,20 @@ 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. - const resolved = route(app, connection.req.path) orelse { + // stable identity and a bounded cookie copy: no request/header/path slice + // survives the callback. + const resolved = route(app, connection.io, 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, ""); + 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) { + error.ClientLimitReached => @enumFromInt(1013), + error.CookieTooLarge => .policy_violation, + else => .internal_error, + }, ""); }; } @@ -3336,7 +4015,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, ""); @@ -3371,7 +4060,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; }; @@ -3390,13 +4079,13 @@ 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}); 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}); } @@ -3472,6 +4161,7 @@ fn onMessage( binding, &decoded, packet.payload, + app.upgradeCookies(connection.io, @intFromPtr(connection)), ) catch { send(connection, app.gpa, packet.header, "") catch {}; }; @@ -3503,61 +4193,539 @@ fn onMessage( .navigation, .client = client, .data = data, + .cookies = app.upgradeCookies(connection.io, @intFromPtr(connection)), }) catch |err| window.log(.err, "WebUI event dispatch failed: {}", .{err}); }, else => connection.wsClose(.unsupported_data, ""), } -} +} + +fn onClose(connection: *Linsang.Connection, user_data: ?*anyopaque) void { + const app = appFrom(user_data); + const upgrade = app.removeUpgrade(connection.io, @intFromPtr(connection)) orelse return; + 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| if (err != error.WindowDestroyed) + window.log(.err, "WebUI event dispatch failed: {}", .{err}); + } +} + +test "multi packet accumulator completes exactly and rejects overflow" { + const gpa = std.testing.allocator; + var multi = try MultiPacket.init(gpa, 9); + defer multi.deinit(gpa); + + try std.testing.expect(!try multi.append("abc")); + try std.testing.expect(!try multi.append("defgh")); + try std.testing.expectError(error.MessageTooLarge, multi.append("ij")); + try std.testing.expect(try multi.append("i")); + try std.testing.expectEqualStrings("abcdefghi", multi.bytes); +} + +const LoggerCapture = struct { + calls: usize = 0, + level: std.log.Level = .debug, + message: [128]u8 = undefined, + message_len: usize = 0, +}; + +fn captureLogger( + level: std.log.Level, + message: []const u8, + user_data: ?*anyopaque, +) void { + const capture: *LoggerCapture = @ptrCast(@alignCast(user_data.?)); + capture.calls += 1; + capture.level = level; + capture.message_len = @min(message.len, capture.message.len); + @memcpy(capture.message[0..capture.message_len], message[0..capture.message_len]); +} + +fn failingEventHandler(_: *const Event, _: ?*anyopaque) !void { + return error.ExpectedLoggerFailure; +} + +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 "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 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); + } + 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 waitForShown(second, io); + + const started = std.Io.Clock.Timestamp.now(io, .awake); + try disconnectTestStream(reloaded, io); + try waiting.await(io); + const elapsed = started.untilNow(io).raw.toMilliseconds(); + try std.testing.expect(elapsed >= reconnect_grace.toMilliseconds() - 50); + } -fn onClose(connection: *Linsang.Connection, user_data: ?*anyopaque) void { - const app = appFrom(user_data); - const window = app.removeUpgrade(connection.io, @intFromPtr(connection)) orelse return; - if (window.disconnected(connection)) |client| { - window.dispatchEvent(connection.io, .{ - .kind = .disconnected, - .client = client, - }) catch |err| - window.log(.err, "WebUI event dispatch failed: {}", .{err}); + { + 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 "multi packet accumulator completes exactly and rejects overflow" { +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 multi = try MultiPacket.init(gpa, 9); - defer multi.deinit(gpa); + 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 {}; - try std.testing.expect(!try multi.append("abc")); - try std.testing.expect(!try multi.append("defgh")); - try std.testing.expectError(error.MessageTooLarge, multi.append("ij")); - try std.testing.expect(try multi.append("i")); - try std.testing.expectEqualStrings("abcdefghi", multi.bytes); + // 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 disconnectTestStream(stream, io); + 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)); } -const LoggerCapture = struct { - calls: usize = 0, - level: std.log.Level = .debug, - message: [128]u8 = undefined, - message_len: usize = 0, -}; +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); -fn captureLogger( - level: std.log.Level, - message: []const u8, + 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")); +} + +fn siteTestHandler( + path: []const u8, + _: *const Request, + response: *Response, user_data: ?*anyopaque, -) void { - const capture: *LoggerCapture = @ptrCast(@alignCast(user_data.?)); - capture.calls += 1; - capture.level = level; - capture.message_len = @min(message.len, capture.message.len); - @memcpy(capture.message[0..capture.message_len], message[0..capture.message_len]); +) 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 failingEventHandler(_: *const Event, _: ?*anyopaque) !void { - return error.ExpectedLoggerFailure; +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 noopCallHandler(_: *Call, _: ?*anyopaque) !void {} +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 "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").?); + 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; @@ -3859,13 +5027,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); @@ -3926,8 +5097,27 @@ 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, + }; +} + +/// 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..100) |_| { + for (0..2000) |_| { if (flag.load(.acquire)) return; try std.Io.sleep(io, .fromMilliseconds(1), .awake); } @@ -4311,9 +5501,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( @@ -4323,6 +5510,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, @@ -4562,7 +5762,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(); } @@ -4821,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, .{}); @@ -4839,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. @@ -4863,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); @@ -4895,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; @@ -5611,7 +6910,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(); @@ -5759,16 +7058,19 @@ 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, + first_window.state.monitor_task.token.load(.acquire) == null, + ); + try std.testing.expect( + second_window.state.monitor_task.token.load(.acquire) == null, ); } 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(); @@ -5816,7 +7118,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); @@ -5830,7 +7132,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(); @@ -5918,7 +7220,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); @@ -5931,7 +7233,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(); @@ -6026,12 +7328,12 @@ 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); } 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(); @@ -6298,7 +7600,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); } @@ -6318,9 +7620,9 @@ 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(!app.ever_connected.load(.acquire)); + try std.testing.expect(!window.state.ever_connected.load(.acquire)); } const client = try connectTestWebSocket( @@ -6456,8 +7758,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; @@ -6698,7 +8002,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( @@ -6712,14 +8016,152 @@ 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)); } +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" { - 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(); @@ -6753,7 +8195,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); @@ -7140,7 +8582,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)) { @@ -7191,7 +8633,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)); } @@ -7306,7 +8748,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); @@ -7427,17 +8869,31 @@ 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); + // 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); + 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)); } @@ -7548,8 +9004,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); @@ -7564,8 +9023,11 @@ 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); + upgrade.window.release(); + }; } test "upgrade allocation failures do not retain admission ownership" { @@ -7708,6 +9170,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); diff --git a/src/browser.zig b/src/browser.zig index a25f31b..3ff6805 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,11 +511,95 @@ 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); } +/// 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 }); +} + +/// 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; @@ -561,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| { @@ -572,45 +686,138 @@ 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, 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; } @@ -685,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", @@ -791,9 +1012,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, @@ -830,13 +1054,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) { @@ -873,6 +1102,93 @@ 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 "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); @@ -897,6 +1213,75 @@ 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 "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, diff --git a/src/native.zig b/src/native.zig index ac7d4fd..7c7a5ee 100644 --- a/src/native.zig +++ b/src/native.zig @@ -11,7 +11,11 @@ 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; +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, @@ -24,6 +28,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 { @@ -40,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 { @@ -83,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 }; @@ -184,6 +203,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); @@ -269,6 +302,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(); @@ -280,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 { @@ -291,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; @@ -307,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 21a9535..b6e18ef 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,104 @@ 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" }; +/// 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); + 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,8 +151,10 @@ 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, 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, @@ -67,11 +171,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, @@ -94,6 +206,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, @@ -104,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, @@ -152,13 +271,20 @@ 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: [1]c_ulong = .{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, @@ -168,6 +294,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 +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 }; + 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; @@ -230,25 +357,35 @@ 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)); + 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. - 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); @@ -257,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; @@ -272,23 +410,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}; + 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); @@ -309,6 +448,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 { @@ -344,13 +487,35 @@ 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 { _ = try self.liveWindow(); self.process_failed = false; + self.host_navigation = true; self.api.webkit_web_view_load_uri(self.view.?, url); } @@ -403,12 +568,18 @@ 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; } 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 { @@ -532,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; @@ -541,12 +737,84 @@ 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; 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; @@ -564,3 +832,27 @@ 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).?); +} + +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/macos.zig b/src/native/macos.zig index cd71312..10ebd4e 100644 --- a/src/native/macos.zig +++ b/src/native/macos.zig @@ -127,7 +127,9 @@ 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")) or + !truth(class_addMethod(class, sel_registerName("webView:decidePolicyForNavigationAction:decisionHandler:"), @ptrCast(&decidePolicy), "v@:@@@?"))) return error.NativeInitializationFailed; objc_registerClassPair(class); delegate_class = class; @@ -169,6 +171,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 @@ -189,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 @@ -208,13 +245,21 @@ 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, 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. @@ -243,9 +288,11 @@ 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, + .follow_page_title = options.follow_page_title, }; errdefer self.destroy(); self.delegate = send0(Id, send0(Id, delegate_class, "alloc"), "init") orelse return error.NativeInitializationFailed; @@ -256,6 +303,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); @@ -279,11 +327,17 @@ 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); - 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,11 +364,16 @@ 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"); 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); @@ -322,6 +381,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); @@ -372,6 +432,15 @@ 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 dragRegion(_: *const Backend) types.DragRegion { + return .window_background; } pub fn close(self: *Backend) !void { @@ -383,14 +452,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(); @@ -399,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; @@ -488,6 +604,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); @@ -496,6 +613,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)); } } @@ -572,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)); +} diff --git a/src/native/types.zig b/src/native/types.zig index 526917b..91e7558 100644 --- a/src/native/types.zig +++ b/src/native/types.zig @@ -10,6 +10,18 @@ pub const Geometry = struct { size: Size, }; +/// 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, +}; + /// Borrowed native window, invalid after native close or owner destruction. pub const Handle = union(enum) { cocoa: *anyopaque, @@ -21,8 +33,37 @@ 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", + /// 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, @@ -35,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, @@ -69,6 +114,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 0555030..b7357bd 100644 --- a/src/native/windows.zig +++ b/src/native/windows.zig @@ -16,8 +16,16 @@ 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_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. +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: { @@ -38,7 +46,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); @@ -192,51 +201,91 @@ 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); +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; + 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; +} + +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, @@ -250,8 +299,18 @@ pub const Backend = struct { webview: ?*Com = null, close_callback: ?*CloseCallback = null, 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, @@ -259,6 +318,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, @@ -270,11 +330,13 @@ 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, .frameless = options.frameless, .transparent = options.transparent, + .follow_page_title = options.follow_page_title, }; errdefer self.destroy(); self.next = live_windows; @@ -295,7 +357,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)}); @@ -310,13 +372,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); @@ -338,11 +400,32 @@ 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 }; + // 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)); 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; + 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. @@ -353,7 +436,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); } @@ -370,7 +453,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); } @@ -405,11 +488,26 @@ 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); 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.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(); @@ -418,6 +516,12 @@ 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; + 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 { @@ -468,7 +572,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 } @@ -486,19 +593,81 @@ 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; 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; @@ -550,6 +719,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; @@ -788,6 +961,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; @@ -801,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)); +} diff --git a/src/root.zig b/src/root.zig index 9049274..eea107a 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; @@ -17,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;