Jerboa Browser MVP: programmable Qt WebEngine pane controlled from Jerboa
ober
e794e206fbd2d1ed6ba82f8b8057b361f21c5264
new file mode 100644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# CMake build trees +build/ +**/build/ + +# macOS +.DS_Store new file mode 100644 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Jerboa Browser + +**Experimental.** A programmable browser pane controlled from the Jerboa REPL. + +Qt WebEngine is the first backend (the logical surface is backend-neutral so a +future Servo backend could implement the same `jwb_*` C ABI). The pane loads +pages, navigates, evaluates JavaScript, and enforces a deny-by-default +capability policy on top of the Chromium sandbox. + +## Layout + +``` +include/jerboa_browser.h C ABI shared by every backend +qt-webengine/ Qt WebEngine backend (CMake; builds libjerboa_browser) + src/ C++ shim over QWebEngine{Profile,Page,View} + probe/ Ticket 0.1 standalone proof-of-host (kept for re-checks) + tests/ ABI/handle tests (ctest) +scheme/ Jerboa wrapper module + tests (.ss) +notes/ host pin, design watchpoints +jerboa-browser.md living plan / ticket log +``` + +## Build (macOS, Homebrew Qt) + +```sh +cmake -S qt-webengine -B qt-webengine/build -DCMAKE_PREFIX_PATH=/opt/homebrew +cmake --build qt-webengine/build +./qt-webengine/build/jwb_demo +``` + +## Security model + +Object-capability, deny-by-default: a context grants only the capabilities +named at creation (`JWB_CAP_NETWORK`, …); everything else is blocked. The +Chromium sandbox stays enabled — `QTWEBENGINE_DISABLE_SANDBOX` is never set for +normal runs. See `jerboa-browser.md`. new file mode 100755 --- /dev/null +++ b/bin/jerboa-browser @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# jerboa-browser — run a Jerboa REPL or script with the (browser) library and +# its native dylib on the path. +# +# Resolves the repo-local Chez + Jerboa stdlib, puts scheme/ on --libdirs so +# (import (browser)) works, and points JERBOA_BROWSER_LIB at the built dylib. +# +# Usage: +# bin/jerboa-browser # REPL with (browser) preloaded +# bin/jerboa-browser repl +# bin/jerboa-browser test # run scheme/browser-test.ss +# bin/jerboa-browser run <file> # run a Jerboa script +# +# Override JERBOA_HOME / SCHEME / JERBOA_BROWSER_LIB via the environment. + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +JERBOA_HOME="${JERBOA_HOME:-$HOME/mine/jerboa}" +SCHEME="${SCHEME:-$JERBOA_HOME/.chez/bin/scheme}" +LIBDIRS="$JERBOA_HOME/lib:$REPO/scheme" + +export JERBOA_BROWSER_LIB="${JERBOA_BROWSER_LIB:-$REPO/qt-webengine/build/libjerboa_browser.dylib}" + +if [ ! -x "$SCHEME" ]; then + echo "Error: Chez Scheme not found at $SCHEME (set SCHEME or JERBOA_HOME)" >&2 + exit 1 +fi +if [ ! -f "$JERBOA_BROWSER_LIB" ]; then + echo "Error: backend dylib not found at $JERBOA_BROWSER_LIB" >&2 + echo "Build it: cmake -S qt-webengine -B qt-webengine/build -DCMAKE_PREFIX_PATH=/opt/homebrew && cmake --build qt-webengine/build" >&2 + exit 1 +fi + +case "${1:-repl}" in + repl) + exec "$SCHEME" --libdirs "$LIBDIRS" --program <(cat <<'REPL' +(import (jerboa prelude) (browser) (std repl)) +(display ";; (browser) loaded — call (browser-init) then (browser-open-context ...)\n") +(jerboa-repl) +REPL + ) + ;; + test) + exec "$SCHEME" --libdirs "$LIBDIRS" --script "$REPO/scheme/browser-test.ss" + ;; + run) + shift + [ $# -ge 1 ] || { echo "usage: jerboa-browser run <file>" >&2; exit 1; } + exec "$SCHEME" --libdirs "$LIBDIRS" --script "$1" + ;; + *) + echo "usage: jerboa-browser [repl|test|run <file>]" >&2 + exit 1 + ;; +esac new file mode 100644 --- /dev/null +++ b/include/jerboa_browser.h @@ -0,0 +1,172 @@ +/* jerboa_browser.h — C ABI for the Jerboa browser backend (Qt WebEngine first). + * + * Backend-neutral by intent: these jwb_* operations are the logical surface a + * future Servo backend would also implement. The current implementation lives + * in qt-webengine/ and owns QWebEngineProfile / QWebEnginePage / QWebEngineView. + * + * Conventions (see jerboa-browser.md "C ABI draft"): + * - 0 is never a valid handle. + * - Handles are tagged internally as context or view; mixing kinds is an error. + * - All Qt object access happens on the Qt GUI (main) thread. + * - jwb_last_error is thread-local. + * - Strings passed in are borrowed for the duration of the call only. + * - Strings passed to callbacks are borrowed for the callback duration only. + * - jwb_eval_js is asynchronous (QWebEnginePage::runJavaScript is async). + * - A callback may fire after teardown; the impl rejects stale handles rather + * than touching freed Qt objects. + */ +#ifndef JERBOA_BROWSER_H +#define JERBOA_BROWSER_H + +#include <stdint.h> +#include <stddef.h> + +#if defined(_WIN32) +# define JWB_API __declspec(dllexport) +#else +# define JWB_API __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef uint64_t JwbHandle; + +typedef enum { + JWB_OK = 0, + JWB_ERR_INVALID_HANDLE = 1, + JWB_ERR_INVALID_ARGUMENT = 2, + JWB_ERR_QT = 3, + JWB_ERR_POLICY_BLOCKED = 4, + JWB_ERR_UNSUPPORTED = 5, + JWB_ERR_INTERNAL = 6, + JWB_ERR_TIMEOUT = 7 +} JwbStatus; + +typedef enum { + JWB_CAP_NETWORK = 1u << 0, + JWB_CAP_CLIPBOARD = 1u << 1, + JWB_CAP_FILESYSTEM = 1u << 2, + JWB_CAP_DOWNLOADS = 1u << 3, + JWB_CAP_PERSISTENT_STORAGE = 1u << 4, + JWB_CAP_DEVTOOLS = 1u << 5, + JWB_CAP_POPUPS = 1u << 6 +} JwbCapabilityFlags; + +typedef void (*JwbStringCallback)(const uint8_t *ptr, + uintptr_t len, + void *userdata); + +typedef void (*JwbStatusCallback)(JwbStatus status, + const uint8_t *ptr, + uintptr_t len, + void *userdata); + +/* --- version / error ---------------------------------------------------- */ +JWB_API const char *jwb_version(void); +JWB_API const char *jwb_last_error(void); /* thread-local; valid until next call */ + +/* --- runtime ------------------------------------------------------------ */ +/* Creates the single QApplication if none exists. Must be called on the main + * thread before any context/view call. Idempotent. */ +JWB_API JwbStatus jwb_runtime_init(void); + +/* --- contexts (own a QWebEngineProfile + capability set) ---------------- */ +JWB_API JwbStatus jwb_context_new(uint32_t capability_flags, JwbHandle *out); +JWB_API JwbStatus jwb_context_free(JwbHandle context); + +/* --- views (own a QWebEngineView + QWebEnginePage) ---------------------- */ +JWB_API JwbStatus jwb_view_new(JwbHandle context, JwbHandle *out); +JWB_API JwbStatus jwb_view_free(JwbHandle view); + +/* --- navigation --------------------------------------------------------- */ +JWB_API JwbStatus jwb_load_url(JwbHandle view, const uint8_t *url, uintptr_t len); +JWB_API JwbStatus jwb_back(JwbHandle view); +JWB_API JwbStatus jwb_forward(JwbHandle view); +JWB_API JwbStatus jwb_reload(JwbHandle view); +JWB_API JwbStatus jwb_stop(JwbHandle view); + +/* --- async JavaScript evaluation (the REPL bridge primitive) ------------ */ +JWB_API JwbStatus jwb_eval_js(JwbHandle view, + const uint8_t *code, + uintptr_t len, + JwbStatusCallback callback, + void *userdata); + +/* --- async observers ---------------------------------------------------- */ +JWB_API JwbStatus jwb_current_url(JwbHandle view, + JwbStringCallback callback, + void *userdata); +JWB_API JwbStatus jwb_title(JwbHandle view, + JwbStringCallback callback, + void *userdata); + +/* === Synchronous convenience layer ===================================== + * For single-threaded embeddings (the Jerboa REPL) that do NOT run their own + * Qt event loop. These drive the GUI event loop internally and block until the + * operation completes or times out. Hosts that already spin a Qt event loop + * (Ticket 2.2) should prefer the async primitives above. + * + * The _sync string getters return a heap-allocated UTF-8 C string the caller + * must release with jwb_string_free (NULL on error; check out_status). + */ +JWB_API JwbStatus jwb_pump_events(uint32_t max_ms); + +JWB_API JwbStatus jwb_load_url_sync(JwbHandle view, + const uint8_t *url, + uintptr_t len, + uint32_t timeout_ms); + +JWB_API char *jwb_eval_js_sync(JwbHandle view, + const uint8_t *code, + uintptr_t len, + uint32_t timeout_ms, + JwbStatus *out_status); + +JWB_API char *jwb_title_sync(JwbHandle view, JwbStatus *out_status); +JWB_API char *jwb_current_url_sync(JwbHandle view, JwbStatus *out_status); + +JWB_API void jwb_string_free(char *s); + +/* --- pane control (Ticket 2.2) ------------------------------------------ */ +JWB_API JwbStatus jwb_view_show(JwbHandle view); +JWB_API JwbStatus jwb_view_hide(JwbHandle view); +JWB_API JwbStatus jwb_view_focus(JwbHandle view); +/* Set the pane window title (NUL-terminated UTF-8). */ +JWB_API JwbStatus jwb_view_set_title(JwbHandle view, const char *utf8_title); + +/* --- shared event loop (Ticket 2.2) ------------------------------------- */ +/* Operate on the single QApplication (reused if the host created one). jwb_exec + * blocks running the loop until jwb_quit (or the last window closes). Hosts + * that own their own exec() never need these; the REPL uses jwb_pump_events. */ +JWB_API JwbStatus jwb_exec(void); +JWB_API JwbStatus jwb_quit(void); + +/* === Jerboa-friendly facade (Ticket 2.1) =============================== + * The canonical ABI above uses output-pointer handles and malloc'd return + * strings — both awkward to bind from a Chez `foreign-procedure`. This facade + * composes those entry points (adding no Qt dependency) so that: + * - handles are returned by value (0 on error), + * - strings are returned in a thread-local buffer, borrowed until this + * thread's next facade string call (the caller never frees them), + * - the JwbStatus of the most recent facade call is read via jwb_last_status. + * Plain NUL-terminated C strings are taken instead of (ptr,len) pairs. Targets + * the synchronous, single-threaded Jerboa REPL embedding. The plain-int return + * of jwb_context_free / jwb_view_free / jwb_back / ... is already FFI-friendly, + * so those are called directly without a facade wrapper. + */ +JWB_API int jwb_last_status(void); /* JwbStatus of the most recent facade call */ +JWB_API JwbHandle jwb_context_open(uint32_t capability_flags); /* 0 on error */ +JWB_API JwbHandle jwb_view_open(JwbHandle context); /* 0 on error */ +JWB_API int jwb_load(JwbHandle view, const char *url, uint32_t timeout_ms); +JWB_API const char *jwb_eval(JwbHandle view, const char *code, + uint32_t timeout_ms); +JWB_API const char *jwb_get_title(JwbHandle view); +JWB_API const char *jwb_get_url(JwbHandle view); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* JERBOA_BROWSER_H */ new file mode 100644 --- /dev/null +++ b/jerboa-browser.md @@ -0,0 +1,765 @@ +# Jerboa Browser — Qt WebEngine First Plan + +Living document — created 2026-05-24, rewritten 2026-05-25. Honest, no hype, +no stubs. This is a plan for an Opus 4.7-level agent to execute in small +tickets. + +## Current decision + +Use **Qt WebEngine as the first real Jerboa browser backend**. + +Servo remains the preferred long-term research backend for a literal +single-binary, Rust-heavy, minimal-TCB browser. But Servo is not complete enough +today to justify making it the first implementation path. The practical path is +to build the Jerboa browser API, REPL workflow, policy layer, and Qt integration +on top of Qt WebEngine now, while keeping the backend boundary clean enough +that Servo can replace or supplement it later. + +The revised product goal is: + +- **First deliverable:** a useful hackable browser pane in the Jerboa/Qt + ecosystem, controlled from the Jerboa REPL. +- **Security model:** Chromium/Qt WebEngine sandboxing plus Jerboa-side policy: + deny-by-default permissions, request interception, no ambient file/download/ + clipboard authority, and explicit browser contexts. +- **Distribution model:** one self-contained app artifact, not one literal + executable. Qt WebEngine requires `QtWebEngineProcess` and resource files. +- **Long-term option:** keep Servo as an experimental backend once it is mature + enough to be worth the integration cost. + +This changes the old plan deliberately: **shipping a usable browser surface is +more important than preserving the literal one-ELF ideal for the first backend**. + +## Why Qt WebEngine now + +Qt WebEngine is based on Chromium, exposes a native Qt widget +(`QWebEngineView`), runs page rendering and JavaScript in `QtWebEngineProcess`, +supports process isolation, receives Chromium security patches through Qt +releases, and exposes the exact primitive needed for the gurf-style REPL bridge: +`QWebEnginePage::runJavaScript`. + +The cost is deployment purity. Qt WebEngine applications must ship the WebEngine +process executable and Chromium resource files. That means this backend cannot +honestly satisfy "single binary" if that phrase means one executable file with +no adjacent resources. It can satisfy "single app artifact" through a `.app`, +AppImage, tarball, or installer bundle. + +## Engine decision + +| Engine | Role | Completeness | Qt integration | Security posture | Distribution | +| --- | --- | --- | --- | --- | --- | +| **Qt WebEngine** | **Primary backend now** | Highest; Chromium-based | Native `QWebEngineView` | Chromium process model + Qt policy hooks | Self-contained app artifact; not literal single binary | +| **Servo** | Future research backend | Incomplete for arbitrary modern web | No maintained Qt widget | Best philosophical fit if mature | Best shot at literal static binary later | +| **QtWebKit-reloaded** | Fallback only | Older, incomplete web compat | Native-ish Qt widget | C++/JavaScriptCore, older security posture | Easier than WebEngine, less capable | + +Decision: implement **Qt WebEngine first**. Revisit Servo only after the Qt +backend proves the Jerboa UX and API. + +## Non-goals + +- Do not implement Servo first. +- Do not spend Phase 0/1 time trying to force Qt WebEngine into one literal + executable. +- Do not expose arbitrary browser authority to pages because Chromium has a + sandbox. The Jerboa host still controls network policy, downloads, + filesystem access, clipboard, popups, devtools, persistence, and custom + schemes. +- Do not make a general daily-driver browser first. The MVP is a programmable + browser pane with load, navigation, policy, and JavaScript evaluation. + +## Desired Jerboa API + +The API should be backend-neutral. `webengine` should be an implementation +detail, not visible in normal user code: + +```scheme +(import (jerboa prelude) (std gui browser)) + +(def caps + (browser-capabilities + network: (network-policy allow-hosts: '("example.com")) + clipboard: #f + filesystem: #f + downloads: #f + persistent-storage: #f + devtools: #f)) + +(def ctx (browser-context caps)) +(def v (browser-open ctx "https://example.com")) + +(browser-eval-js v "document.title") +(browser-load v "about:blank") +(browser-close v) +(browser-context-close ctx) +``` + +MVP may temporarily expose lower-level names, but the public target is +`(std gui browser)`. + +## Architecture + +Use the existing Jerboa/Qt shape: Jerboa calls a small C ABI; C++ owns the Qt +objects. + +```text +Jerboa REPL (.ss) + (browser-open ctx url) / (browser-eval-js view code) + │ foreign-procedure + ▼ +C ABI (jwb_*) + │ C-compatible boundary, opaque handles + ▼ +Qt browser shim (C++) + │ owns QWebEngineProfile / QWebEnginePage / QWebEngineView + ▼ +Qt WebEngine / Chromium + │ separate QtWebEngineProcess for rendering + JavaScript + ▼ +Qt application window / browser pane +``` + +No Rust layer is needed for the Qt WebEngine backend. Keep the ABI narrow so a +future Servo backend can implement the same logical operations. + +## Repository layout + +This is a dedicated `jerboa-browser` repo, so the workspace **is the repo root** +— there is no extra `browser/` prefix (decided 2026-05-24). All ticket paths +below are relative to the repo root. Keep browser work opt-in: nothing here is +wired into a default build yet. + +```text +. # repo root == jerboa-browser/ + README.md + jerboa-browser.md # this plan (living doc) + include/ + jerboa_browser.h + qt-webengine/ + probe/ # Ticket 0.1 — DONE + CMakeLists.txt + main.cpp + build/ # cmake build dir (gitignore) + CMakeLists.txt + src/ + browser_abi.cpp + browser_handles.cpp + browser_page.cpp + browser_policy.cpp + browser_view.cpp + main_demo.cpp + tests/ + scheme/ + browser.ss + browser-test.ss + packaging/ + README.md + notes/ + qt-webengine-pin.md # Ticket 0.1 — DONE + policy-audit.md +``` + +Build targets should be opt-in: + +```text +make browser-webengine-demo +make browser-webengine-test +make package-browser +``` + +Do not add Servo files in this phase. + +## C ABI draft + +This is the ABI Opus 4.7 should implement unless real Qt constraints force a +small documented change. + +```c +typedef uint64_t JwbHandle; + +typedef enum { + JWB_OK = 0, + JWB_ERR_INVALID_HANDLE = 1, + JWB_ERR_INVALID_ARGUMENT = 2, + JWB_ERR_QT = 3, + JWB_ERR_POLICY_BLOCKED = 4, + JWB_ERR_UNSUPPORTED = 5, + JWB_ERR_INTERNAL = 6 +} JwbStatus; + +typedef enum { + JWB_CAP_NETWORK = 1u << 0, + JWB_CAP_CLIPBOARD = 1u << 1, + JWB_CAP_FILESYSTEM = 1u << 2, + JWB_CAP_DOWNLOADS = 1u << 3, + JWB_CAP_PERSISTENT_STORAGE = 1u << 4, + JWB_CAP_DEVTOOLS = 1u << 5, + JWB_CAP_POPUPS = 1u << 6 +} JwbCapabilityFlags; + +typedef void (*JwbStringCallback)(const uint8_t *ptr, + uintptr_t len, + void *userdata); + +typedef void (*JwbStatusCallback)(JwbStatus status, + const uint8_t *ptr, + uintptr_t len, + void *userdata); + +const char *jwb_version(void); +const char *jwb_last_error(void); + +JwbStatus jwb_runtime_init(void); +JwbStatus jwb_context_new(uint32_t capability_flags, JwbHandle *out); +JwbStatus jwb_context_free(JwbHandle context); + +JwbStatus jwb_view_new(JwbHandle context, JwbHandle *out); +JwbStatus jwb_view_free(JwbHandle view); + +JwbStatus jwb_load_url(JwbHandle view, const uint8_t *url, uintptr_t len); +JwbStatus jwb_back(JwbHandle view); +JwbStatus jwb_forward(JwbHandle view); +JwbStatus jwb_reload(JwbHandle view); +JwbStatus jwb_stop(JwbHandle view); + +JwbStatus jwb_eval_js(JwbHandle view, + const uint8_t *code, + uintptr_t len, + JwbStatusCallback callback, + void *userdata); + +JwbStatus jwb_current_url(JwbHandle view, + JwbStringCallback callback, + void *userdata); +JwbStatus jwb_title(JwbHandle view, + JwbStringCallback callback, + void *userdata); +``` + +Rules: + +- `0` is never a valid handle. +- Handles are tagged internally as context or view. +- All Qt object access happens on the Qt GUI thread. +- `jwb_last_error` is thread-local. +- Strings passed into C++ are borrowed only for the duration of the call. +- Strings passed to callbacks are borrowed only for the callback duration. +- `jwb_eval_js` is asynchronous because `QWebEnginePage::runJavaScript` is + asynchronous. +- A callback may fire after page teardown; the implementation must detect stale + handles and return an error instead of touching deleted Qt objects. + +## Qt WebEngine policy layer + +Each `browser-context` owns a `QWebEngineProfile`. Start with off-the-record +profiles unless `JWB_CAP_PERSISTENT_STORAGE` is granted. + +Implement these policy defaults: + +- Network: denied unless `JWB_CAP_NETWORK` is present. After MVP, support + allowlists by scheme, host, and port. +- Clipboard: denied unless `JWB_CAP_CLIPBOARD` is present. +- Filesystem/local file access: denied unless `JWB_CAP_FILESYSTEM` is present. +- Downloads: denied unless `JWB_CAP_DOWNLOADS` is present. +- Persistent storage: denied by default; use off-the-record profiles. +- Popups/new windows: denied unless `JWB_CAP_POPUPS` is present. +- DevTools/remote debugging: denied unless `JWB_CAP_DEVTOOLS` is present. +- JavaScript: enabled for MVP because `browser-eval-js` is a core feature. + Add per-context disable later if needed. + +Qt hooks to verify and use: + +- `QWebEngineProfile` for profile isolation, settings, cookies, downloads, and + URL request interception. +- `QWebEngineUrlRequestInterceptor` to block or allow requests. +- `QWebEnginePage::runJavaScript` for REPL evaluation. +- `QWebEnginePage` permission signals for geolocation, media, notifications, + clipboard, and file-system requests where available. +- `QWebEnginePage::createWindow` / new-window handling to deny or control + popups. +- `QWebEngineSettings` to disable risky local-content and clipboard behavior. + +Do not use `QWebChannel` in the MVP. It is useful later, but it expands the +attack surface between page JavaScript and Jerboa. + +## Packaging plan + +For Qt WebEngine, the target is a **single app artifact**, not a literal +single executable. + +Required deployment pieces normally include: + +- main Jerboa/Qt executable +- Qt libraries +- Qt WebEngine libraries +- `QtWebEngineProcess` +- Chromium `.pak` resource files +- translations and platform plugins as required +- app signing/notarization metadata on macOS + +Platform targets: + +- macOS: `.app` bundle first. +- Linux: AppImage or self-contained tarball first. +- Windows: optional later. + +Acceptance for packaging: + +- The packaged artifact runs from a clean temporary directory. +- It does not depend on files from the developer's Qt install path. +- The path to `QtWebEngineProcess` is controlled by the bundle/package, not by + ambient environment. +- `QTWEBENGINE_DISABLE_SANDBOX=1` is not used for normal runs. + +## Security stance + +This backend is not "pure" in the Jerboa philosophy sense: Chromium and V8 are +large C++/JIT components. The security argument is instead practical: + +- Chromium is a mature browser engine with a real process model. +- Qt WebEngine separates rendering and JavaScript into `QtWebEngineProcess`. +- Qt WebEngine supports site/process isolation modes. +- Qt tracks Chromium releases and cherry-picks security patches. +- Jerboa adds a host-side object-capability policy layer instead of giving the + page ambient authority. + +That is good enough for a first useful browser. It is likely much safer in +practice than trying to ship a half-integrated incomplete engine. + +## Agent execution contract for Opus 4.7 + +Follow this section literally. + +General rules: + +- Implement one ticket at a time. +- Do not implement Servo in this plan. +- Do not attempt literal single-binary Qt WebEngine. +- Do not touch sibling repositories. +- Keep all browser work opt-in. +- If a Qt API name in this document is wrong, check the official Qt docs, + update this document with the real API, then continue. +- Every ticket must leave commands and notes for the next ticket. + +Stop conditions: + +- Qt WebEngine is not installed and cannot be installed on the host. +- A minimal `QWebEngineView` app cannot build. +- `QtWebEngineProcess` cannot be found or launched in the demo. +- `runJavaScript` cannot return `document.title`. +- The sandbox must be disabled for ordinary page loading. + +If a stop condition is hit, record the command, error, Qt version, host OS, and +recommended next action in `browser/notes/qt-webengine-pin.md`. + +## Implementation tickets + +### Ticket 0.1 — Qt WebEngine probe — DONE (2026-05-24) + +Goal: prove the local machine can build and run Qt WebEngine. + +Result: PASS on macOS 15.5 arm64, Qt 6.11.0 (Homebrew). Probe loaded +`https://example.com` and `runJavaScript("document.title")` returned +`"Example Domain"` with the sandbox enabled. Full host facts in +`notes/qt-webengine-pin.md`. + +Created: + +```text +notes/qt-webengine-pin.md +qt-webengine/probe/{CMakeLists.txt,main.cpp} +``` + +The probe may be a tiny CMake project that creates a `QApplication`, a +`QWebEngineView`, loads `https://example.com`, and runs: + +```cpp +view->page()->runJavaScript("document.title", ...); +``` + +Record in `qt-webengine-pin.md`: + +- OS and version. +- Qt version. +- Compiler version. +- CMake command. +- Required Qt packages/modules. +- Location of `QtWebEngineProcess`. +- Whether sandboxing stayed enabled. + +Acceptance (met): + +```text +cmake -S qt-webengine/probe -B qt-webengine/probe/build -DCMAKE_PREFIX_PATH=/opt/homebrew +cmake --build qt-webengine/probe/build +./qt-webengine/probe/build/jwb_probe +``` + +The app opens a window, loads a page, and logs the page title. + +### Ticket 0.2 — Browser workspace skeleton — DONE (2026-05-24) + +Result: PASS. `include/jerboa_browser.h` (full C ABI), `qt-webengine/` +(`CMakeLists.txt`, `src/browser_internal.h`, `src/browser_abi.cpp`, +`src/main_demo.cpp`), `README.md` created. `cmake --build` produces +`libjerboa_browser.dylib` + `jwb_demo`; demo prints +`jerboa-browser 0.0.1 (Qt WebEngine 6.11.0)` and `runtime initialized OK` +(exit 0). `jwb_runtime_init` makes the single QApplication (reuses an existing +one for the Ticket 2.2 host case). + +Goal: add the real opt-in workspace. + +Create the repository layout from the "Repository layout" section. + +Requirements: + +- `browser/README.md` says this is experimental and Qt WebEngine-first. +- `browser/include/jerboa_browser.h` contains the C ABI draft. +- `browser/qt-webengine/CMakeLists.txt` builds a placeholder library and demo. +- Placeholder exports: `jwb_version`, `jwb_last_error`, `jwb_runtime_init`. +- No Jerboa Scheme wrapper yet. + +Acceptance: + +```text +cmake -S qt-webengine -B qt-webengine/build -DCMAKE_PREFIX_PATH=/opt/homebrew +cmake --build qt-webengine/build +``` + +Default repository build behavior remains unchanged. + +### Ticket 1.1 — Handle registry and ABI tests — DONE (2026-05-24) + +Result: PASS. `src/browser_handles.cpp` (monotonic, never-reused handle +registry, mutex-guarded; tagged Context/View; `jwb_context_new/free`, +`jwb_view_new/free`), `src/browser_utf8.cpp` (well-formed UTF-8 validator), +`tests/{abi_test.cpp,CMakeLists.txt}`. `ctest` green: covers null pointer, +invalid handle, wrong kind, double free/stale, and UTF-8 (overlong, surrogate, +truncated, >U+10FFFF). Test needs no QApplication. Handle payloads are minimal +placeholders that Ticket 1.2 grows into Qt objects. + +Goal: make the ABI safe before it owns browser objects. + +Implement: + +- context handle allocation/free +- view handle allocation/free placeholder +- internal handle tags +- stale-handle rejection +- thread-local last error +- null pointer checks +- UTF-8 validation for string inputs + +Acceptance: + +- C++ tests cover invalid handle, wrong handle kind, double free, null pointer, + invalid UTF-8, and last error. +- A tiny C or C++ harness can call the ABI without Qt WebEngine objects. + +### Ticket 1.2 — Real WebEngine context/view — DONE (2026-05-24) + +Result: PASS. Added `src/browser_objects.h` (Qt-backed Context/View payloads + +inline string helpers), `src/browser_view.cpp` (backend seam: off-the-record +`QWebEngineProfile`, `QWebEngineView`+`QWebEnginePage`; navigation via +`triggerAction`; sync title/url observers; show/hide), `src/browser_page.cpp` +(`jwb_eval_js` async + the whole sync layer: `pump_events`, `load_url_sync`, +`eval_js_sync`, `title_sync`, `current_url_sync`, `string_free`). Demo output: +`load … OK`, `document.title = "Example Domain"`, `current url = +https://example.com/`, `freed view with eval in flight: no crash`, `closed +cleanly` (exit 0). + +Design notes for later tickets: +- The handle layer (`browser_handles.cpp`) stays Qt-free; it reaches Qt only + through `jwb::backend_context_create/destroy` + `backend_view_create/destroy`. + The ABI test injects fake backends, so `ctest` needs no QApplication. +- The view is never `show()`n in the demo; QtWebEngineProcess loads regardless. +- Teardown safety: freeing a view drops its page, and Qt cancels the page's + pending `runJavaScript` callback — no use-after-free. The sync helpers block + via `processEvents(WaitForMoreEvents, 20)` until done or timeout. +- A failed/blocked load surfaces from `jwb_load_url_sync` as `JWB_ERR_QT` + (Ticket 1.3 reuses this for the no-network-cap case). + +Goal: connect ABI handles to real Qt WebEngine objects. + +Implement: + +- `jwb_context_new` creates a context with a `QWebEngineProfile`. +- `jwb_view_new` creates a `QWebEngineView` and `QWebEnginePage`. +- `jwb_view_free` destroys the view on the Qt GUI thread. +- `jwb_load_url` loads a URL. +- `jwb_title` and `jwb_current_url` report observable state. +- `jwb_eval_js` calls `QWebEnginePage::runJavaScript`. + +Acceptance: + +- Demo opens a view, loads `https://example.com`, prints title via + `jwb_eval_js`, then closes without crash. +- Closing before the JavaScript callback returns does not touch freed objects. + +### Ticket 1.3 — Policy MVP — DONE (2026-05-24) + +Result: PASS. `src/browser_policy.cpp` adds `install_policy(Context*)`, called +from `backend_context_create`. Verified Qt 6.11 API against the framework +headers first. Mechanisms, all keyed off capability flags: +- Network: `NetworkInterceptor : QWebEngineUrlRequestInterceptor` blocks every + request whose scheme isn't about/data/qrc/blob unless `JWB_CAP_NETWORK`. +- Downloads: `profile->downloadRequested` → `cancel()` unless `JWB_CAP_DOWNLOADS` + (logs `[policy] download denied`). +- Popups: `QWebEngineSettings::JavascriptCanOpenWindows = POPUPS`; plus the + default `createWindow` → nullptr. +- Filesystem: `LocalContentCanAccessFileUrls = FILESYSTEM`; + `LocalContentCanAccessRemoteUrls = false` always. +- Clipboard: `JavascriptCanAccessClipboard = CLIPBOARD`. +- DevTools/remote-debugging never enabled. +Interceptor owned by Context, deleted after the profile in +`backend_context_destroy`. + +Demo (`jwb_demo`) now asserts all four acceptance bullets and prints +`ALL CHECKS PASSED (0 failures)`, exit 0: network ctx loads example.com; +`window.open()` returns null (denied); download cancelled; **no-capability ctx +is blocked from the network**. Sandbox stays enabled. + +Goal: deny obvious authority by default. + +Implement: + +- off-the-record profile by default +- request interceptor that denies network when `JWB_CAP_NETWORK` is absent +- download-deny handler unless `JWB_CAP_DOWNLOADS` is present +- new-window/popup deny unless `JWB_CAP_POPUPS` is present +- local file access disabled unless `JWB_CAP_FILESYSTEM` is present +- devtools disabled unless `JWB_CAP_DEVTOOLS` is present + +Acceptance: + +- Demo with no network cap fails to load `https://example.com`. +- Demo with network cap loads `https://example.com`. +- Attempted download is denied by default. +- Attempted popup is denied by default. + +### Ticket 2.1 — Jerboa wrapper module — DONE (2026-05-24) + +Result: `scheme/browser.ss` is a `(library (browser) ...)` over a Jerboa-friendly +C facade (`qt-webengine/src/browser_ffi.cpp`: handles returned by value, return +strings in a thread-local buffer, `jwb_last_status`) bound with `(jerboa ffi)` +`define-c-lambda`. `scheme/browser-test.ss` drives the real backend: **15/15 +checks pass** (load example.com, `document.title == "Example Domain"`, URL read, +view≠context type-safety, no-cap network deny, double-close rejection). Verified +with `jerboa_compile_check` + `jerboa_verify`, then run live via +`bin/jerboa-browser test`. Decisions: **block through the Qt event loop** (sync +facade — no future object in MVP); contexts/views are distinct `defstruct` types; +explicit `browser-close-view` / `browser-close-context` are primary (no +finalizers). Actual API names (lower-level per MVP allowance): `browser-init`, +`browser-version`, `browser-capabilities` (variadic symbols, e.g. +`(browser-capabilities 'network 'downloads)`), `browser-open-context`, +`browser-open-view`, `browser-load`, `browser-eval`, `browser-title`, +`browser-url`, `browser-back/forward/reload/stop`, `browser-show/hide`, +`browser-close-view`, `browser-close-context`, predicates `browser-context?` / +`browser-view?` / `browser-ok?` / `browser-error?` / `browser-value`. NOTE: +`[...]` is a list literal in the Jerboa reader — clause groups use parens. + +Goal: expose the ABI to Jerboa. + +Create: + +```text +scheme/browser.ss +scheme/browser-test.ss +``` + +Surface API: + +```scheme +(browser-capabilities . kwargs) +(browser-context caps) +(browser-open ctx url) +(browser-load view url) +(browser-eval-js view code) +(browser-title view) +(browser-url view) +(browser-close view) +(browser-context-close ctx) +``` + +Requirements: + +- Validate argument types before FFI. +- Tag handles so context/view handles cannot be mixed. +- Make explicit close primary; finalizers are backup only. +- Return Jerboa result values: `(ok value)` or `(err message)`. +- For async JavaScript, either block through the Qt event loop in MVP or expose + a small promise/future object. Document the choice before implementing. + +Acceptance: + +- A Jerboa script opens a page, gets `document.title`, and closes the view. +- Invalid handle and invalid argument tests exist. +- After `.ss` edits, run the build target required by `AGENTS.md`. + +### Ticket 2.2 — Qt host integration — DONE (2026-05-24) + +Result: `qt-webengine/host/host_demo.cpp` (`jwb_host`) is a real Qt host that +**owns** the QApplication + `app.exec()` loop; `jwb_runtime_init` reuses it via +`qobject_cast` (no second QApplication — asserted live). Backend gained +`jwb_view_focus`, `jwb_view_set_title`, `jwb_exec`, `jwb_quit` +(`qt-webengine/src/browser_host.cpp`); Jerboa gained `browser-focus`, +`browser-set-title`, `browser-pump`, `browser-exec`, `browser-quit`. The host +shows a titled pane, loads example.com through its own event loop, reads +`document.title` == "Example Domain", and frees the view exactly once (second +free → INVALID_HANDLE): **8/8 PASS**. CMake option `JWB_HOST_ENABLE_BROWSER` +(default ON); with `=OFF` the browser code `#ifdef`s out and `jwb_host` links +**no** WebEngine / jerboa_browser (verified via `otool -L`) yet still starts and +exits cleanly — the "app runs without browser support" path. Jerboa +`browser-test.ss` now also exercises show/title/focus/pump/hide: **20/20 PASS**. +Pages enter Qt's Discarded state once `exec()` quits, so all verification runs +while the page is live, before the cosmetic display hold. + +Goal: put the working WebEngine view inside the real Qt host. + +Requirements: + +- Do not create a second `QApplication`. +- Reuse the existing Qt event loop. +- Add the minimum API to create, show, focus, and close a browser pane. +- Closing the pane frees the WebEngine view exactly once. + +Acceptance: + +- Jerboa can open a browser pane inside the Qt app. +- The normal Qt app still starts without browser support when the browser + target is disabled. + +### Ticket 3.1 — Packaged app artifact — DONE (2026-05-24) + +Result (macOS, the verified target): +- `packaging/macos/build-app.sh` builds `JerboaBrowser.app` (~299 MB) via + `macdeployqt`, then makes it genuinely self-contained: a relative symlink so + the nested `QtWebEngineProcess.app` resolves `@executable_path/../Frameworks`, + `install_name_tool` to rebind the helper's deps + three stale install-ids off + `/opt/homebrew`, ad-hoc re-sign, and a final scan of every Mach-O that fails + the build if any `/opt/homebrew` reference survives. +- **Acceptance met:** copied to a fresh `mktemp -d` and launched + (`JWB_HOST_SMOKE=1`), the WebEngine helpers spawn and load + `https://example.com` (`document.title = "Example Domain"`), 9/9 host checks + PASS, exit 0, **Chromium sandbox left enabled**. `codesign --verify --strict` + passes; zero `/opt/homebrew` runtime deps. +- `packaging/README.md` lists every bundled component (19 frameworks, ~35 + dylibs incl. `libjerboa_browser.dylib`, the helper app, WebEngine `.pak` + + `icudtl.dat` resources, platform/imageformat/tls/etc. plugins) and documents + the fixups and why macdeployqt alone SIGABRTs on first page load. +- Linux: `packaging/linux/build-tarball.sh` stages backend `.so` + `scheme/` + + launcher relying on system Qt6 WebEngine; marked **not verified on the macOS + dev host**, with linuxdeployqt/AppImage noted as the no-prerequisite path. + +Goal: make deployment honest and reproducible. + +Implement packaging notes/scripts under `browser/packaging/`. + +Requirements: + +- Package the main executable, Qt libraries, platform plugins, + `QtWebEngineProcess`, WebEngine resources, and translations. +- macOS target: `.app`. +- Linux target: AppImage or tarball. +- Do not rely on the developer's Qt install tree at runtime. +- Do not disable sandboxing. + +Acceptance: + +- Running from a clean temporary directory works. +- The package can load `https://example.com`. +- `browser/packaging/README.md` lists every bundled component and any accepted + system libraries. + +### Ticket 4.1 — Servo backend watchpoint — DONE (2026-05-24) + +Result: `notes/servo-watchpoint.md` created. It records why we ship on Qt +WebEngine now, the six conditions that would justify starting a Servo backend +(stable embedding API, reliable offscreen surface, JS-eval API, cross-platform +event loop, realistic page compatibility, credible static-resource story), and +that Servo would slot in as a new backend behind the existing C ABI. **No Servo +code added** — the note is the only Servo artifact in the repo. + +Goal: keep the long-term path visible without blocking Qt WebEngine. + +Do not implement Servo. Create only a short note: + +```text +notes/servo-watchpoint.md +``` + +It should list conditions that would justify starting a Servo backend: + +- stable public embedding API +- reliable offscreen rendering surface +- JavaScript evaluation API +- cross-platform event loop story +- realistic page compatibility for the target Jerboa use cases +- credible static resource story + +Acceptance: + +- The note exists. +- No Servo code is added. + +## Build and test matrix + +Expected commands as implementation progresses: + +```text +cmake -S qt-webengine/probe -B qt-webengine/probe/build -DCMAKE_PREFIX_PATH=/opt/homebrew +cmake --build qt-webengine/probe/build + +cmake -S qt-webengine -B qt-webengine/build -DCMAKE_PREFIX_PATH=/opt/homebrew +cmake --build qt-webengine/build +ctest --test-dir qt-webengine/build + +make browser-webengine-demo +make browser-webengine-test +make package-browser