Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/actions/headless-host/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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"
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 17 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 0 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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"
Expand All @@ -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",
] }
Expand Down
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 29 additions & 12 deletions apps/chuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,7 +12,7 @@ license.workspace = true
authors.workspace = true
repository.workspace = true
homepage.workspace = true
publish = false
publish = true

[[bin]]
name = "chuzz-gui"
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -131,15 +139,19 @@ 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 }
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",
Expand All @@ -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

Expand All @@ -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 }
24 changes: 23 additions & 1 deletion apps/chuzz/build.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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()))
Expand All @@ -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()));
Expand All @@ -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"),
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion apps/chuzz/src/headless_main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = std::env::args().collect();
let target = match serve::target_from(&args) {
Ok(target) => target,
Expand Down
22 changes: 22 additions & 0 deletions apps/chuzz/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading
Loading