diff --git a/.github/actions/headless-host/action.yml b/.github/actions/headless-host/action.yml index 887d3e1..b6a8ba0 100644 --- a/.github/actions/headless-host/action.yml +++ b/.github/actions/headless-host/action.yml @@ -26,10 +26,10 @@ inputs: file can use a field an older driver does not know, and the failure then reads as a broken workflow rather than a driver that is too old. - 0.6.3 is the floor because it is the first that reads `QA_TIMEOUT_SCALE` - from the environment. Every site workflow sets it, so an older driver - silently runs a shared runner against the strict local latency contract. - default: "^0.6.3" + 0.7.1 uses the shared control protocol and rejects checks that silently + inherit another file's page. Keep the driver on the same protocol line + as the headless host. + default: "^0.7.1" outputs: host: @@ -52,6 +52,12 @@ runs: - name: Install Rust uses: dtolnay/rust-toolchain@stable + - name: Install fonts for rendered outcomes + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libfontconfig1-dev fonts-dejavu-core + - uses: Swatinem/rust-cache@v2 with: workspaces: .qa-host @@ -77,5 +83,5 @@ runs: # development headers to build a browser that never opens a window. cargo build --release --manifest-path .qa-host/Cargo.toml \ --bin chuzz-headless --no-default-features \ - --features capture,javascript,vello,scrollbars,webp + --features capture,javascript,scrollbars,webp,system-fonts echo "host=$PWD/.qa-host/target/release/chuzz-headless" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6f2200..33a3e9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,7 +155,7 @@ jobs: shared-key: chuzz-macos-ci - name: Clippy - run: cargo clippy -p chuzz-gui --all-targets -- -D warnings + run: cargo clippy -p chuzz --all-targets -- -D warnings - name: Test run: cargo test --workspace diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0713b2..fb6f0cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,10 +62,23 @@ jobs: - name: Compare the committed version against the published one id: decide run: | - # `cargo pkgid` rather than a TOML parser. The version is resolved by - # the tool that owns it, so this cannot disagree with what the build - # actually produces, and it needs no interpreter on the runner. - VERSION=$(cargo pkgid -p chuzz-gui | sed 's/.*@//') + # Cargo reads the version rather than a TOML parser, so this cannot + # disagree with what the build produces and needs no interpreter on + # the runner. `--no-deps` reads the workspace manifests only. + # + # Not `cargo pkgid`: that resolves the dependency graph and so refuses + # to run without a `Cargo.lock`, which this repo deliberately does not + # commit. It exited non-zero here, left VERSION empty, and the job ran + # on to compare the built bundle against an empty string. 0.1.37 was + # merged, tagged and never published that way. Hence the guard below: + # an unreadable version fails the release instead of publishing a + # nameless one. + VERSION=$(cargo metadata --no-deps --format-version 1 \ + | sed -n 's/.*"name":"chuzz","version":"\([^"]*\)".*/\1/p') + if [ -z "$VERSION" ]; then + echo "::error::could not read chuzz's version from cargo metadata" + exit 1 + fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" # `latest.json` is written by this workflow further down, so its shape diff --git a/.gitignore b/.gitignore index 3326ed4..039ce90 100644 --- a/.gitignore +++ b/.gitignore @@ -22,10 +22,6 @@ apps/chuzz/gen/ # exist on one machine. See scripts/local-engine.sh. .cargo/local-engine.toml -# Python bytecode from scripts/corpus/ -__pycache__/ -*.pyc - # Lockfiles are not committed here. Every dependency is a caret range on a # published version, so a build resolves the newest thing that satisfies it and # a broken upstream release fails the build that introduced it. A committed lock diff --git a/Cargo.toml b/Cargo.toml index 87791f8..2cd68d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,10 @@ blitz-wasm = { package = "ps-blitz-wasm", version = "^0.4" } dioxus-native = { package = "ps-dioxus-native", version = "^0.7.3", default-features = false } # The MCP framing the control server speaks, same source and feature as # tauri-runtime-blitz uses for its agent-control surface. +# The one control surface: wire vocabulary, the document core, and both +# transports. It used to be reached through `tauri-runtime-blitz`, which meant a +# headless browser compiled a Tauri runtime with the runtime switched off. +blitz-control-protocol = { version = "^0.5", default-features = false } endpoint-libs = { version = "^3", default-features = false, features = ["agent-control"] } brotli = { version = "8", default-features = false, features = ["std"] } image = { version = "^0.25.6", default-features = false } @@ -98,6 +102,14 @@ tokio-tungstenite = { version = "0.30", default-features = false, features = [ "handshake", "rustls-tls-native-roots", ] } +# Named directly only so the binary can install a crypto provider by name. See +# `install_crypto_provider`: rustls otherwise picks one from crate features and +# panics at the first handshake when the resolution enables neither provider or +# both, and with no lockfile that is not fixed at any point in time. +rustls = { version = "0.23", default-features = false, features = [ + "aws-lc-rs", + "std", +] } futures-util = { version = "0.3", default-features = false, features = ["sink"] } tokio = "1" url = "2.5" @@ -118,7 +130,7 @@ tauri = { version = "^2.11.5", default-features = false } # explaining. A window that would not paint could not be photographed by the # one thing that could photograph it, because the capability had been optimised # out of the binary that needed it. -tauri-runtime-blitz = { version = "^0.3.7", default-features = false, features = [ +tauri-runtime-blitz = { version = "^0.4.0", default-features = false, features = [ "agent-control", "diagnostics", ] } diff --git a/README.md b/README.md index fd2853f..935bcd7 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ chrome is a SolidJS app interpreted by Boa, the same way page content is, and ev it is Rust. ```sh -cargo run -p chuzz-gui # opens a blank tab -cargo run -p chuzz-gui -- example.com # opens a bare hostname over HTTPS -cargo run -p chuzz-gui -- --wasm demo.wasm # a tab a WebAssembly guest builds +cargo run -p chuzz --bin chuzz-gui # opens a blank tab +cargo run -p chuzz --bin chuzz-gui -- example.com # opens a bare hostname over HTTPS +cargo run -p chuzz --bin chuzz-gui -- --wasm demo.wasm # a tab a WebAssembly guest builds ``` A non-URL argument is not a search: anything that is neither a URL nor a hostname @@ -95,6 +95,31 @@ Events are not wired up yet: the page renders and does not respond. ## Rendering without a window +For interactive website QA, build the headless host with fonts and use ps-qa +0.7.1 or newer: + +```sh +cargo build --release --bin chuzz-headless --no-default-features \ + --features capture,javascript,scrollbars,webp,system-fonts +ps-qa --app ../worktables.dev/tests/ps-qa/ps-qa.ron qa-hosted \ + --host target/release/chuzz-headless --page ../worktables.dev/dist \ + --checks ../worktables.dev/tests/ps-qa/checks +``` + +Linux needs `pkg-config`, `libfontconfig1-dev`, and a font catalogue such as +`fonts-dejavu-core`; the shared headless-host CI action installs these. No desktop +server is needed. The `system-fonts` feature is optional for embedders supplying +their own fonts, but rendered website checks need real glyphs. + +The host dispatches input through the shared `DocumentControl` implementation, +including pointer gestures, key-down/up, and scrolling. Once an action has been +applied, a page that continues animating does not turn it into a failed action +that a caller might repeat; subsequent inspection observes the resulting state. +WebSocket dispatch snapshots the registered listeners, so a listener removing +itself cannot skip another queued RPC waiting for the same connection to open. +Listeners removed before their turn are skipped; newly added listeners wait for +the next dispatch. + ```sh chuzz-gui --capture out.png https://example.com # a fetched page chuzz-gui --capture-wasm demo.wasm --out out.png --tree out.txt diff --git a/apps/chuzz/Cargo.toml b/apps/chuzz/Cargo.toml index 790f0bc..1865edb 100644 --- a/apps/chuzz/Cargo.toml +++ b/apps/chuzz/Cargo.toml @@ -1,6 +1,9 @@ [package] -# Package and binary share the name, matching AgencyZero's `apps/gui` -> `az-gui`. -name = "chuzz-gui" +# The package is the browser as a library, so an embedder can depend on it. +# The binaries keep their own names: `chuzz-gui` is the window, `chuzz-headless` +# the inspection host, and both are referenced by build-app.sh, the release +# workflow and the Homebrew cask. +name = "chuzz" description = "A pure Rust web browser" version.workspace = true edition.workspace = true @@ -9,7 +12,7 @@ license.workspace = true authors.workspace = true repository.workspace = true homepage.workspace = true -publish = false +publish = true [[bin]] name = "chuzz-gui" @@ -52,12 +55,14 @@ default = [ # rasteriser and the inspection socket, which is everything ps-qa drives. gui = [ "dep:tauri", + "dep:tauri-runtime-blitz", + "dep:tauri-build", "tauri-runtime-blitz/runtime", # The window paints glyphs, so it wants real faces. On Linux this reaches # parley's enumeration and therefore fontconfig, which is why it is here and - # not in the base dependencies: a headless build must not want a font stack. - "blitz-dom/system-fonts", - "blitz-script?/system-fonts", + # not in the base dependencies. Rendered QA opts in through the same feature. + "system-fonts", + "dep:dioxus-native", "dioxus-native/system-fonts", ] # Let a WebAssembly guest build a page, through `--wasm` in the window and @@ -69,6 +74,9 @@ wasm = ["dep:blitz-wasm", "dep:wasmi"] # Most of the web builds its DOM in JavaScript: without this a script-rendered # page parses to an empty mount point and paints nothing. javascript = ["dep:blitz-script"] +# Opt in for rendered QA as well as GUI builds. A minimal headless embedder +# can still omit font discovery and provide its own faces. +system-fonts = ["blitz-dom/system-fonts", "blitz-script?/system-fonts"] # The `image` crate is pulled in with default features off, so without these a # .webp or .avif downloads fine and then cannot be decoded: the element simply # never paints, which reads as a missing asset rather than a missing codec. @@ -101,8 +109,8 @@ capture = [ "wasm", ] avif = ["image/avif"] -vello = ["dioxus-native/vello"] -vello-hybrid = ["dioxus-native/vello-hybrid"] +vello = ["dioxus-native?/vello"] +vello-hybrid = ["dioxus-native?/vello-hybrid"] # `incremental` was a feature flag upstream; PR #599 made it a runtime setting on # DocumentConfig, so the forwarding feature no longer has anything to forward to. scrollbars = ["blitz-dom/scrollbars"] @@ -131,7 +139,6 @@ blitz-script = { workspace = true, optional = true } blitz-traits = { workspace = true, default-features = true } blitz-wasm = { workspace = true, optional = true } wasmi = { workspace = true, optional = true } -chuzz-control = { path = "../../crates/chuzz-control" } brotli.workspace = true image = { workspace = true, default-features = false } anyrender = { workspace = true, optional = true } @@ -139,7 +146,12 @@ anyrender_vello_cpu = { workspace = true, optional = true } blitz-paint = { workspace = true, optional = true } png = { workspace = true, optional = true } flate2.workspace = true -dioxus-native = { workspace = true, features = [ +# Nothing in this crate imports `dioxus_native`. It is here for the window's +# renderer selection, so it is optional and `gui` turns it on. While it was +# unconditional it rejected a build that named no renderer, which is why the +# headless feature set carried `vello`, and `vello` is wgpu, winit and AppKit in +# a browser that opens no window. +dioxus-native = { workspace = true, optional = true, features = [ "accessibility", "clipboard", "net", @@ -151,7 +163,9 @@ url.workspace = true serde.workspace = true serde_json.workspace = true tauri = { workspace = true, optional = true } -tauri-runtime-blitz.workspace = true +rustls.workspace = true +blitz-control-protocol = { workspace = true, features = ["engine", "server", "capture"] } +tauri-runtime-blitz = { workspace = true, optional = true } tokio-tungstenite.workspace = true futures-util.workspace = true @@ -177,4 +191,7 @@ wat = "1" [build-dependencies] brotli.workspace = true -tauri-build = { version = "2", features = [] } +# Only the window needs a Tauri context. Optional so a headless build does not +# compile `tauri-build` and its two transitives for a binary whose `build.rs` +# never calls it: the same fault as the runtime dependency, one plane down. +tauri-build = { version = "2", features = [], optional = true } diff --git a/apps/chuzz/build.rs b/apps/chuzz/build.rs index 03f37e1..e5940b4 100644 --- a/apps/chuzz/build.rs +++ b/apps/chuzz/build.rs @@ -1,11 +1,21 @@ +use std::process::Command; + +// Everything below is the embedded browser chrome, which only a `gui` build +// compiles. Off that feature these are dead, and an unused import is a warning +// the workspace denies. +#[cfg(feature = "gui")] use std::fs; +#[cfg(feature = "gui")] use std::io::Write; +#[cfg(feature = "gui")] use std::path::{Path, PathBuf}; -use std::process::Command; +#[cfg(feature = "gui")] use brotli::CompressorWriter; +#[cfg(feature = "gui")] const CSS_MARKER: &str = "__CHUZZ_EMBEDDED_CSS__"; +#[cfg(feature = "gui")] const JS_URL: &str = "chuzz://ui/__chuzz__/app.js"; /// First line of a command's stdout, or `None` when it fails or prints nothing. @@ -51,6 +61,7 @@ fn stamp_build() { println!("cargo:rerun-if-changed=src"); } +#[cfg(feature = "gui")] fn only_file_with_extension(directory: &Path, extension: &str) -> PathBuf { let mut matches = fs::read_dir(directory) .unwrap_or_else(|error| panic!("cannot read {}: {error}", directory.display())) @@ -68,6 +79,7 @@ fn only_file_with_extension(directory: &Path, extension: &str) -> PathBuf { path } +#[cfg(feature = "gui")] fn compress_asset(path: &Path, output: &Path, quality: u32) -> usize { let input = fs::read(path).unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); @@ -84,6 +96,7 @@ fn compress_asset(path: &Path, output: &Path, quality: u32) -> usize { /// Compile and Brotli-embed the Solid browser chrome using the same asset /// loading shape as AgencyZero's Blitz document factory. +#[cfg(feature = "gui")] fn build_frontend() { let manifest_dir = PathBuf::from( std::env::var_os("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR"), @@ -170,6 +183,15 @@ fn strip_unused_frameworks() { fn main() { strip_unused_frameworks(); stamp_build(); + // The Solid browser chrome, consumed only by `frontend.rs`, which is itself + // behind `gui`. Building it unconditionally meant `cargo build --bin + // chuzz-headless --no-default-features` shelled out to `bun run build` for + // assets that binary never links, and then failed on any machine where + // `apps/chuzz/frontend/node_modules` was not installed. That is every CI + // runner using the headless-host action, which installs the site's + // dependencies and has no reason to install this crate's. It took the whole + // fleet's QA red. + #[cfg(feature = "gui")] build_frontend(); // Generates the Tauri context, which only the `chuzz-gui` binary consumes. // A headless build has no `tauri` in its graph for the context to describe, diff --git a/apps/chuzz/src/headless_main.rs b/apps/chuzz/src/headless_main.rs index bf50d0d..6dde359 100644 --- a/apps/chuzz/src/headless_main.rs +++ b/apps/chuzz/src/headless_main.rs @@ -11,9 +11,13 @@ //! uses. See that module for why the host is a mode of the browser instead of a //! second one. -use chuzz_gui::serve; +use chuzz::serve; fn main() { + // Before the loader can reach the network. See the function's own comment + // for why the provider is named here rather than left to the resolution. + chuzz::install_crypto_provider(); + let args: Vec = std::env::args().collect(); let target = match serve::target_from(&args) { Ok(target) => target, diff --git a/apps/chuzz/src/lib.rs b/apps/chuzz/src/lib.rs index fa63d6e..7ce675c 100644 --- a/apps/chuzz/src/lib.rs +++ b/apps/chuzz/src/lib.rs @@ -14,6 +14,28 @@ //! the harness, so the harness measured a browser nobody ships. There is one //! loader now, and one place a gap gets fixed. +/// Name the TLS provider, rather than letting the resolver imply one. +/// +/// `rustls` selects its cryptographic provider from crate features, and panics +/// at the first handshake when the graph enables neither `ring` nor `aws-lc-rs` +/// or enables both. Features are additive across a dependency graph, so which +/// of those holds is an outcome of resolution rather than a decision anyone +/// made. This repository commits no lockfile, so it is not fixed at any point +/// in time either: one crate picking up `ring` in a later release is enough to +/// turn every `https://` fetch and every `wss://` connection into a panicked +/// worker on the next runner that resolves it. +/// +/// It presents as a site bug rather than a browser one. The socket never +/// opens, Solid halts reactivity on the escaped error, and the page collapses +/// to unnamed nodes, so a QA run reports a broken site. +/// +/// Both binaries call this before anything can reach the network. An `Err` +/// means a provider is already installed, which is the outcome being asked +/// for, so it is discarded. +pub fn install_crypto_provider() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); +} + // The window and its Tauri command surface. Behind `gui` because `tauri` is, // and because a headless build has no window to drive. #[cfg(feature = "gui")] diff --git a/apps/chuzz/src/serve.rs b/apps/chuzz/src/serve.rs index ce0a45b..0be5575 100644 --- a/apps/chuzz/src/serve.rs +++ b/apps/chuzz/src/serve.rs @@ -1,4 +1,14 @@ -//! Serve one page over the inspection socket, with no window. +//! The headless runtime for Blitz: one page, no window, driven over the +//! control socket. +//! +//! The pair to `tauri-runtime-blitz`, which is the same engine embedded in a +//! Tauri window. That one is an adapter, about 4,000 lines letting Tauri host +//! a Blitz document. This is the browser itself with the window left off, so +//! it is a module here rather than a crate of its own: it needs +//! `document_loader`, `page_server`, `nav` and `identity`, which is most of a +//! browser, and the last attempt to package it separately is the cautionary +//! tale below. +//! //! //! # Why this is here and not in a second crate //! @@ -32,24 +42,21 @@ //! waits for the line, so a component that wedges the engine cannot poison the //! next one's verdict. -use std::num::NonZeroUsize; use std::path::Path; use std::sync::Arc; use std::sync::mpsc; +use blitz_control_protocol::document::{DocumentCapture, inspect_document, snapshot_document}; +use blitz_control_protocol::in_process::DocumentControl; +use blitz_control_protocol::server::{AgentControlServer, ControlBridgeRequest, Host}; +use blitz_control_protocol::{ + AgentControlRequest, DebugError, DebugEvent, DebugResponse, DiagnosticsRequest, + WindowComposition, +}; use blitz_dom::Document as _; use blitz_script::ScriptDocument; -use blitz_traits::events::{BlitzImeEvent, UiEvent}; use blitz_traits::net::Url; use blitz_traits::shell::{ColorScheme, Viewport}; -use tauri_runtime_blitz::control_protocol::{ - AgentAction, AgentControlRequest, DebugError, DebugEvent, DebugResponse, DiagnosticsRequest, - InputCommand, KeyPhase, WindowComposition, -}; -use tauri_runtime_blitz::{ - AgentControlServer, ControlBridgeRequest, DocumentCapture, click_agent_node, focus_agent_node, - hover_agent_node, inspect_document, press_agent_key, snapshot_document, -}; fn trace(message: &str) { eprintln!("chuzz-headless: {message}"); @@ -274,7 +281,12 @@ fn settle_response( } Err(failure) => { *painted = failure.painted; - DebugResponse::Error(failure.error) + // The action has already been applied. A busy page is not a failed + // click, and reporting it as one invites callers to repeat writes. + // Leave the remaining work to the idle loop; ps-qa waits for the + // declared outcome within its own deadline. + verbose(&failure.error.message); + DebugResponse::Ack } } } @@ -545,7 +557,7 @@ pub fn serve(target: &str) -> Result<(), String> { tokio::sync::oneshot::Sender, )>(MAX_PENDING_REQUESTS); - let bridge: tauri_runtime_blitz::ControlBridge = Arc::new(move |request| { + let bridge: blitz_control_protocol::server::ControlBridge = Arc::new(move |request| { let (response_tx, response_rx) = tokio::sync::oneshot::channel(); match request_tx.try_send((request, response_tx)) { Ok(()) => response_rx, @@ -572,7 +584,16 @@ pub fn serve(target: &str) -> Result<(), String> { }); let (render_events, render_event_receiver) = tokio::sync::watch::channel(None); - let server = AgentControlServer::start_with_events(bridge, render_event_receiver) + // What this process calls itself over MCP. `diagnostics` is true because + // this binary is built with the protocol's `capture` feature, so + // `blitz.diagnostics` answers rather than erroring: advertising a tool that + // fails every call reads as a broken application instead of a plain build. + let host = Host { + name: "chuzz-headless".to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + diagnostics: true, + }; + let server = AgentControlServer::start_with_events(bridge, host, render_event_receiver) .map_err(|error| format!("could not host the control socket: {error}"))?; trace(&format!( "inspection socket listening: {}", @@ -588,6 +609,7 @@ pub fn serve(target: &str) -> Result<(), String> { let mut revision = 0_u64; let mut render_revision = 0_u64; let mut capture = DocumentCapture::new(); + let mut control = DocumentControl::new(); // When the page was last allowed to run. A request resets nothing on its // own, so this is what keeps the guarantee below true under load. let mut last_tick = std::time::Instant::now(); @@ -615,170 +637,21 @@ pub fn serve(target: &str) -> Result<(), String> { revision += 1; inspect_document(&mut document, root, max_depth, revision) } - AgentControlRequest::Act(AgentAction::Focus { node_id }) => { - let node_id = blitz_dom::NodeId::from_u64(node_id); - match focus_agent_node(&mut document, node_id) { - Ok(()) => settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ), - Err(error) => DebugResponse::Error(error), - } - } - AgentControlRequest::Act(AgentAction::Click { node_id }) => { - match click_agent_node(&mut document, node_id, 1) { - Ok(_) => settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ), - Err(error) => DebugResponse::Error(error), - } - } - AgentControlRequest::Act(AgentAction::ScrollIntoView { node_id }) => { - /* - * Really scrolled, not acknowledged. - * - * The host this replaces served one component on a page - * that never overflowed, so it answered Ack and was right - * by accident. A fleet site scrolls, and a control below - * the fold that is never brought into view is hovered at - * whatever happens to be at its coordinates, which reads as - * a control that does not respond. - */ - let node_id = blitz_dom::NodeId::from_u64(node_id); - document.inner_mut().scroll_to_node(node_id); - settle_response( + AgentControlRequest::Act(action) => match control.act(&mut document, action) { + Ok(()) => settle_response( &mut document, &animation_clock, settle_deadline, &mut painted, - ) - } - AgentControlRequest::Act(AgentAction::Hover { node_id }) => { - /* - * A control revealed on hover is unreachable without this, - * and a defect that only shows on the second entry is - * unreachable even with one hover: a pill whose hover - * appends a shadow layer and never removes it looks right - * once. - */ - match hover_agent_node(&mut document, node_id) { - Ok(_) => settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ), - Err(error) => DebugResponse::Error(error), - } - } - AgentControlRequest::Act(AgentAction::DoubleClick { node_id }) => { - match click_agent_node(&mut document, node_id, 2) { - Ok(_) => settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ), - Err(error) => DebugResponse::Error(error), - } - } - AgentControlRequest::Act(AgentAction::SetValue { node_id, value }) => { - let node_id = blitz_dom::NodeId::from_u64(node_id); - let current = document - .inner() - .get_node(node_id) - .and_then(|node| node.element_data()) - .and_then(|element| element.text_input_data()) - .map(|input| input.editor.text().to_string()); - match current { - None => DebugResponse::Error(DebugError { - code: "notEditable".into(), - message: "node is not a text input".into(), - }), - Some(current) => { - document.inner_mut().set_focus_to(node_id); - /* - * Clear by byte count, not by selecting the text - * first. - * - * `select_all` builds its selection with - * `move_lines(&layout, isize::MAX)`, and - * `select_byte_range` resolves its ends through - * `Cursor::from_byte_index(&layout, ..)`. Both read - * the laid out text, so both depend on a font - * catalogue being present: with none registered - * every glyph shapes to nothing, the selection - * comes back collapsed, and the commit below - * inserts at the caret instead of replacing. - * - * This build has faces, so `select_all` would work - * here. It stays byte arithmetic anyway, because a - * host that behaves differently depending on which - * fonts the machine has is a harness that reports - * different verdicts on CI and on a laptop. - * `delete_bytes_before_selection` and - * `delete_bytes_after_selection` clamp to the ends - * of the buffer, so between them they empty it from - * wherever the caret is, with no layout involved. - */ - if let Some(len) = NonZeroUsize::new(current.len()) { - document.inner_mut().with_text_input(node_id, |mut editor| { - editor.delete_bytes_before_selection(len); - editor.delete_bytes_after_selection(len); - }); - } - document.handle_ui_event(UiEvent::Ime(BlitzImeEvent::Commit(value))); - settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ) - } - } - } - AgentControlRequest::Act(AgentAction::Input(InputCommand::Key { - key, - code, - phase, - .. - })) => { - /* - * One press per Down, and nothing on the matching Up. - * - * `press_agent_key` sends both halves, because a control - * that acts on keyup never fires if only a keydown arrives. - * A client that sends the pair would otherwise press the - * key twice, and Escape pressed twice closes a menu and - * then whatever was behind it. - */ - if matches!(phase, KeyPhase::Up) { - DebugResponse::Ack - } else { - match press_agent_key(&mut document, &key, &code) { - Ok(()) => settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ), - Err(error) => DebugResponse::Error(error), - } - } - } + ), + Err(error) => DebugResponse::Error(error), + }, // Everything else needs runtime state this mode does not have, // and saying so is better than a plausible-looking Ack: a check // that silently did nothing reports the page as broken. _ => DebugResponse::Error(DebugError { code: "unsupported".into(), - message: "the headless page serves Inspect, Focus, Hover, Click, \ - DoubleClick, ScrollIntoView, SetValue and Key only" - .into(), + message: "the headless page does not handle process lifecycle requests".into(), }), }, ControlBridgeRequest::Diagnostics(DiagnosticsRequest::Capture(request)) => { diff --git a/apps/chuzz/src/tauri_main.rs b/apps/chuzz/src/tauri_main.rs index 21e2c6c..52f9e86 100644 --- a/apps/chuzz/src/tauri_main.rs +++ b/apps/chuzz/src/tauri_main.rs @@ -95,8 +95,8 @@ const MENU_VIEW_SOURCE: &str = "menu-view-source"; // The modules live in the library beside this binary, because the headless // host is the same browser and compiles the same tree. See `lib.rs`. #[cfg(feature = "capture")] -use chuzz_gui::capture; -use chuzz_gui::{browser, frontend, nav}; +use chuzz::capture; +use chuzz::{browser, frontend, nav}; use browser::Browser; @@ -114,6 +114,11 @@ fn flag_value(args: &[String], flag: &str) -> Option { } fn main() { + // Before any of the entry points below can reach the network. See the + // function's own comment for why the provider is named here rather than + // left to the resolution. + chuzz::install_crypto_provider(); + // Before `--capture`, and matched by equality rather than by prefix, so the // two flags cannot be confused for each other in either direction. #[cfg(feature = "capture")] diff --git a/apps/chuzz/src/ws_bridge.rs b/apps/chuzz/src/ws_bridge.rs index b0040a6..085fdf2 100644 --- a/apps/chuzz/src/ws_bridge.rs +++ b/apps/chuzz/src/ws_bridge.rs @@ -354,8 +354,16 @@ pub const WEBSOCKET_SHIM: &str = r#" function fire(type, event) { var direct = socket["on" + type]; if (typeof direct === "function") { direct.call(socket, event); } - var registered = listeners[type]; - for (var i = 0; i < registered.length; i++) { registered[i].call(socket, event); } + // A listener may remove itself while handling the event (the RPC adapter + // does this when sending queued requests on open). Iterating the live + // array skips the next request after that removal. New listeners belong + // to the next dispatch; explicitly removed listeners must not run. + var registered = listeners[type].slice(); + for (var i = 0; i < registered.length; i++) { + if (listeners[type].indexOf(registered[i]) >= 0) { + registered[i].call(socket, event); + } + } } this.__deliver = function (event) { diff --git a/apps/chuzz/tests/follows_a_link.rs b/apps/chuzz/tests/follows_a_link.rs index 04930d2..38f3eb9 100644 --- a/apps/chuzz/tests/follows_a_link.rs +++ b/apps/chuzz/tests/follows_a_link.rs @@ -22,7 +22,7 @@ use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -use tauri_runtime_blitz::control_protocol::{ +use blitz_control_protocol::{ AgentAction, AgentControlRequest, DebugResponse, JsonRpcId, MessageStream, TransportStream, decode_response, encode_agent_request, framed_json, }; @@ -65,7 +65,7 @@ async fn request( async fn tree( stream: &mut dyn MessageStream, next_id: &mut i64, -) -> tauri_runtime_blitz::control_protocol::AgentSnapshot { +) -> blitz_control_protocol::AgentSnapshot { let answer = request( stream, next_id, diff --git a/apps/chuzz/tests/serves_inspection.rs b/apps/chuzz/tests/serves_inspection.rs index 94430a1..6e4d8bc 100644 --- a/apps/chuzz/tests/serves_inspection.rs +++ b/apps/chuzz/tests/serves_inspection.rs @@ -23,7 +23,7 @@ use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -use tauri_runtime_blitz::control_protocol::{ +use blitz_control_protocol::{ AgentAction, AgentControlRequest, CaptureRequest, DebugEvent, DebugResponse, DebugStream, DiagnosticsRequest, InputCommand, JsonRpcId, KeyPhase, MessageStream, Modifiers, PointerPhase, TransportStream, WheelPhase, decode_diagnostics_event, decode_response, encode_agent_request, @@ -411,7 +411,7 @@ fn serves_a_page_over_the_inspection_socket() { }), ) .await, - DebugResponse::Error(error) if error.code == "unsupported" + DebugResponse::Ack )); assert!(matches!( @@ -433,7 +433,9 @@ fn serves_a_page_over_the_inspection_socket() { Ok(DebugEvent::PaintCommitted { .. }) )); - for unsupported in [ + // These inputs now use the shared document-control implementation. + // A page without a scroll range still accepts a wheel as a no-op. + for supported in [ AgentControlRequest::Act(AgentAction::Input(InputCommand::Pointer { phase: PointerPhase::Move, x: 1.0, @@ -447,9 +449,13 @@ fn serves_a_page_over_the_inspection_socket() { phase: WheelPhase::Moved, modifiers: Modifiers::default(), })), - AgentControlRequest::Relaunch, - AgentControlRequest::Quit, ] { + assert!(matches!( + request(&mut stream, &mut next_id, &supported).await, + DebugResponse::Ack + )); + } + for unsupported in [AgentControlRequest::Relaunch, AgentControlRequest::Quit] { assert!(matches!( request(&mut stream, &mut next_id, &unsupported).await, DebugResponse::Error(error) if error.code == "unsupported" diff --git a/scripts/local-engine.sh b/scripts/local-engine.sh index 34ba107..679503d 100755 --- a/scripts/local-engine.sh +++ b/scripts/local-engine.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Run a cargo command against the engine working checkouts instead of the pins. # -# scripts/local-engine.sh check -p chuzz-gui -# scripts/local-engine.sh run -p chuzz-gui -- --wasm demo.wasm +# scripts/local-engine.sh check -p chuzz +# scripts/local-engine.sh run -p chuzz --bin chuzz-gui -- --wasm demo.wasm # # Everything after the script name is passed through to cargo unchanged. # diff --git a/scripts/render-check.sh b/scripts/render-check.sh index 005582d..8ba02bd 100755 --- a/scripts/render-check.sh +++ b/scripts/render-check.sh @@ -33,7 +33,7 @@ mkdir -p "$out_dir" if [ ! -x "$binary" ]; then echo "building chuzz-gui with the capture feature" >&2 - ( cd "$repo_dir" && cargo build -q -p chuzz-gui --release --features capture ) + ( cd "$repo_dir" && cargo build -q -p chuzz --bin chuzz-gui --release --features capture ) fi capture_one() {