diff --git a/Cargo.toml b/Cargo.toml index 81f1c6c..8ab9165 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/tauri-runtime-blitz"] [workspace.package] -version = "0.3.7" +version = "0.4.0" edition = "2024" license = "MIT OR Apache-2.0" publish = true diff --git a/crates/tauri-runtime-blitz/Cargo.toml b/crates/tauri-runtime-blitz/Cargo.toml index 8b22122..aa671a7 100644 --- a/crates/tauri-runtime-blitz/Cargo.toml +++ b/crates/tauri-runtime-blitz/Cargo.toml @@ -20,36 +20,34 @@ style = { version = "0.20.0", package = "stylo" } # not here: `system-fonts` should never have been a default of `blitz-dom`, and # every consumer copying its default list is a copy that goes stale the next # time a default is added. Fixed at the source in pathscale/ps-blitz#87. -blitz-dom = { package = "ps-blitz-dom", version = "^0.4", optional = true } +# Still named directly: the runtime downcasts a window.s document and reads +# `NodeId` and the `Document` trait. Nothing about roles, names or capture is +# reached from here any more. +blitz-dom = { package = "ps-blitz-dom", version = "^0.4.8", optional = true } # No `system-fonts`. It reaches `fontique/system` and, on Linux, the # `yeslogic-fontconfig-sys` system library, so enabling it here would decide # that every consumer of this crate needs one installed, including a headless # one that reads no font catalogue. An application that wants the machine's # fonts asks for them itself, and both consumers already do. blitz-script = { package = "ps-blitz-script", version = "^0.4" } -blitz-control-protocol = { version = "^0.4", optional = true } +# The control surface: the vocabulary, the core, and the socket transport this +# runtime serves its own document over. `capture` carries the offscreen paint +# and the diagnostic snapshots, and pulls `engine` and `blitz-shell` with it. +blitz-control-protocol = { version = "^0.5", default-features = false, optional = true } +# The window renderer's own alpha mode, which is why this is not optional: the +# offscreen capture that used to need it here is `blitz-control-protocol`'s. anyrender = { package = "ps-anyrender", version = "^0.13.0" } -# Offscreen capture, behind `diagnostics` so a shipping build carries neither. -# -# The CPU renderer on purpose: capture must not need a GPU, a surface or a -# display server, or it stops working in the places it is most needed (CI, a -# headless Linux box, an Android target). It draws the same scene through the -# same `blitz-paint` entry point the window uses, so what it returns is the -# real frame rather than a second opinion about it. -anyrender_vello_cpu = { package = "ps-anyrender-vello-cpu", version = "^0.17.0", optional = true } -blitz-paint = { package = "ps-blitz-paint", version = "^0.4", optional = true } -base64 = { version = "0.22", optional = true } peniko = "0.6" blitz-shell = { package = "ps-blitz-shell", version = "^0.4", default-features = false, features = ["clipboard", "svg"] } blitz-traits = { package = "ps-blitz-traits", version = "^0.4", optional = true } http = "1" -endpoint-libs = { version = "^3", default-features = false, features = ["agent-control"], optional = true } -keyboard-types = { version = "0.7", optional = true } +# No `endpoint-libs`, `keyboard-types`, `serde` or `serde_json`. They were the +# wire framing, the key table and the request types of a control surface this +# crate no longer implements; `blitz-control-protocol` names them now, and a +# consumer that wants them names it. libc = "0.2" raw-window-handle = "0.6" -serde = { version = "1", features = ["derive"], optional = true } -serde_json = { version = "1", optional = true } tauri = { version = "2", default-features = false, optional = true } tauri-runtime = { version = "2", optional = true } tauri-utils = { version = "2", optional = true } @@ -81,15 +79,27 @@ runtime = [ "dep:tauri-utils", "dep:anyrender_vello", ] -agent-control = ["blitz-shell/debug-control", "dep:blitz-control-protocol", "dep:blitz-dom", "dep:blitz-traits", "dep:endpoint-libs", "dep:keyboard-types", "dep:serde", "dep:serde_json", "dep:tokio"] -debug-control = ["blitz-script/debug-control", "blitz-shell/debug-control"] -diagnostics = [ - "agent-control", - "debug-control", - "dep:anyrender_vello_cpu", - "dep:base64", - "dep:blitz-paint", +# Serve this runtime's own document over the control socket. +# +# The surface itself is `blitz-control-protocol`: `engine` is the core that +# reads and drives a document, `server` is the socket. Neither is this crate. +# What this feature adds here is the wiring: which window's document is the +# active one, the listener's lifetime, and what `Relaunch` and `Quit` mean for +# a process this runtime started. +agent-control = [ + "blitz-control-protocol/engine", + "blitz-control-protocol/server", + "blitz-shell/debug-control", + "dep:blitz-control-protocol", + "dep:blitz-dom", + "dep:blitz-traits", + "dep:tokio", ] +debug-control = ["blitz-script/debug-control", "blitz-shell/debug-control"] +# Collection, which is where the expense is. The offscreen renderer, the +# snapshot readers and the frame metrics are all `blitz-control-protocol`'s +# `capture`; a shipping build that leaves this off carries none of them. +diagnostics = ["agent-control", "blitz-control-protocol/capture", "debug-control"] # Swap the general-purpose Vello renderer for the lighter hybrid pipeline. # UI is overwhelmingly rectangles and text, which Vello tessellates and bins # through a compute pass built for arbitrary vector art. The hybrid backend @@ -107,6 +117,11 @@ macos-private-api = ["runtime", "tauri/macos-private-api", "tauri-runtime/macos- [dev-dependencies] tauri = { version = "2", default-features = false, features = ["test"] } +# The IPC round-trip test builds a request body and reads the reply back. It was +# a dev use of a dependency the crate itself carried for the control protocol, +# so removing that left the test with no JSON: a test dependency now, which is +# what it always was. +serde_json = "1" # Only on macOS, and only for the one test that needs a face: # `setting_a_node_value_replaces_text_and_dispatches_input`. Replacing a diff --git a/crates/tauri-runtime-blitz/src/agent.rs b/crates/tauri-runtime-blitz/src/agent.rs deleted file mode 100644 index 785784d..0000000 --- a/crates/tauri-runtime-blitz/src/agent.rs +++ /dev/null @@ -1,1855 +0,0 @@ -//! Inspecting, capturing and driving a document, with no window involved. -//! -//! Split out of the runtime because none of it needs one. A headless -//! inspection host wants exactly these functions: it serves a socket, answers -//! `Inspect`, and activates nodes. It opens nothing. -//! -//! While they lived beside the Tauri runtime, depending on them meant -//! compiling Tauri, and on Linux that means GTK -- system libraries pulled in -//! to build a binary that never creates a window, and a crate that would not -//! compile there at all. The dependency edge was wrong, not the platform. - -use std::collections::HashMap; - -#[cfg(all(feature = "agent-control", unix))] -use blitz_control_protocol::{ - AgentSnapshot, DebugError, DebugResponse, KeyPhase, Modifiers as ControlModifiers, SemanticNode, -}; -#[cfg(all(feature = "diagnostics", unix))] -use blitz_control_protocol::{ - DebugSnapshot, FrameMetrics, FrameWindowMetrics, LayoutBounds, LayoutDiagnosticRow, - LayoutEdges, LayoutOffset, LayoutSize, RendererMetrics, RevisionSet, ScriptMetrics, - ScriptSource, SnapshotCost, SnapshotRequest, TimingStats, -}; -#[cfg(all(feature = "agent-control", unix))] -use blitz_dom::Document; -use blitz_script::ScriptDocument; -#[cfg(all(feature = "agent-control", unix))] -use blitz_traits::events::{ - BlitzKeyEvent, BlitzPointerEvent, BlitzPointerId, DomEvent, DomEventData, KeyState, - MouseEventButton, MouseEventButtons, Point, PointerCoords, PointerDetails, UiEvent, -}; -#[cfg(all(feature = "diagnostics", unix))] -use blitz_traits::node_id::NodeId; -#[cfg(all(feature = "agent-control", unix))] -use keyboard_types::{Code, Key, Location, Modifiers as KeyboardModifiers}; - -/// The live inspector's reusable offscreen surface. -/// -/// A capture used to construct this whole renderer for every frame. Besides -/// reallocating the viewport-sized RGBA buffer, that threw away the CPU text -/// renderer's glyph resources, so a stability assertion shaped and rasterised -/// every label four times. The surface belongs to one runtime and is resized -/// only when the window or requested scale changes. -#[cfg(all(feature = "diagnostics", unix))] -pub(crate) struct CaptureSurface { - pub(crate) width: u32, - pub(crate) height: u32, - pub(crate) renderer: anyrender_vello_cpu::VelloCpuImageRenderer, - pub(crate) rgba: Vec, -} - -/// Reusable offscreen renderer for captures of one document. -/// -/// A headless inspection host asks for several adjacent frames when it checks -/// visual stability. Reusing this object preserves the CPU renderer's glyph -/// resources and pixel allocation between those requests instead of rebuilding -/// an entire renderer for every sample. -#[cfg(all(feature = "diagnostics", unix))] -pub struct DocumentCapture { - surface: Option, -} - -#[cfg(all(feature = "diagnostics", unix))] -impl DocumentCapture { - pub fn new() -> Self { - Self { surface: None } - } - - pub fn capture( - &mut self, - document: &mut ScriptDocument, - request: blitz_control_protocol::CaptureRequest, - ) -> Result { - capture_document_with_surface(document, request, &mut self.surface) - } -} - -#[cfg(all(feature = "diagnostics", unix))] -impl Default for DocumentCapture { - fn default() -> Self { - Self::new() - } -} - -#[cfg(all(feature = "diagnostics", unix))] -impl CaptureSurface { - pub(crate) fn new(width: u32, height: u32) -> Self { - use anyrender::ImageRenderer as _; - - Self { - width, - height, - renderer: anyrender_vello_cpu::VelloCpuImageRenderer::new(width, height), - rgba: Vec::with_capacity((width as usize) * (height as usize) * 4), - } - } - - pub(crate) fn size_to(&mut self, width: u32, height: u32) { - use anyrender::ImageRenderer as _; - - if self.width == width && self.height == height { - return; - } - self.renderer.resize(width, height); - self.width = width; - self.height = height; - } -} - -/// Draw a standalone script document through the same CPU paint path used by -/// runtime diagnostics. -/// -/// Headless QA hosts intentionally have no `RuntimeApplication`, but they must -/// not substitute a second renderer for native visual checks. Keeping the -/// capture implementation here makes a host capture and a live-app capture -/// byte-for-byte comparable. -#[cfg(all(feature = "diagnostics", unix))] -pub fn capture_document( - script_document: &mut ScriptDocument, - request: blitz_control_protocol::CaptureRequest, -) -> Result { - DocumentCapture::new().capture(script_document, request) -} - -#[cfg(all(feature = "diagnostics", unix))] -pub(crate) fn capture_document_with_surface( - script_document: &mut ScriptDocument, - request: blitz_control_protocol::CaptureRequest, - surface: &mut Option, -) -> Result { - use anyrender::ImageRenderer; - use base64::Engine as _; - - // Clamped rather than trusted. A scale of zero produces a zero-sized - // buffer and a negative one panics inside the rasteriser, and neither - // should be reachable from a debug socket. - let scale = if request.scale.is_finite() && request.scale > 0.0 { - request.scale.clamp(0.1, 8.0) - } else { - 1.0 - }; - - let node_id = request.node_id; - - // Style and layout first, so the capture reflects pending mutations - // rather than the frame before them. Same call `collect_diagnostics` - // makes, for the same reason. - script_document.inner_mut().resolve(0.0); - - // Copied out rather than held: the guard is a `Ref` and the borrow has - // to end before the mutable one the paint below needs. - let (full_width, full_height) = { - let inner = script_document.inner(); - let viewport = inner.viewport(); - (viewport.window_size.0, viewport.window_size.1) - }; - if full_width == 0 || full_height == 0 { - return Err(debug_error( - "captureUnavailable", - "the document has no viewport to draw", - )); - } - - // The region to keep, in unscaled document pixels. - let (crop_x, crop_y, crop_width, crop_height) = match node_id { - None => ( - 0.0_f64, - 0.0_f64, - f64::from(full_width), - f64::from(full_height), - ), - Some(id) => { - let inner = script_document.inner(); - let node = inner - .get_node(NodeId::from_u64(id)) - .ok_or_else(|| debug_error("unknownNode", &format!("no node {id}")))?; - let layout = node.final_layout(); - let position = node.absolute_position(0.0, 0.0); - if layout.size.width <= 0.0 || layout.size.height <= 0.0 { - return Err(debug_error( - "captureEmpty", - &format!("node {id} has a zero-sized box, so there is nothing to capture"), - )); - } - let box_ = ( - f64::from(position.x), - f64::from(position.y), - f64::from(layout.size.width), - f64::from(layout.size.height), - ); - drop(inner); - box_ - } - }; - - let full_pixel_width = ((f64::from(full_width) * f64::from(scale)).round() as u32).max(1); - let full_pixel_height = ((f64::from(full_height) * f64::from(scale)).round() as u32).max(1); - // Clamp before painting: a node partly offscreen yields the visible part, - // and the regional renderer never allocates pixels that will be discarded. - let left = ((crop_x * f64::from(scale)).round().max(0.0) as u32).min(full_pixel_width); - let top = ((crop_y * f64::from(scale)).round().max(0.0) as u32).min(full_pixel_height); - let width = ((crop_width * f64::from(scale)).round() as u32) - .min(full_pixel_width.saturating_sub(left)) - .max(1); - let height = ((crop_height * f64::from(scale)).round() as u32) - .min(full_pixel_height.saturating_sub(top)) - .max(1); - // Leave room for the JSON-RPC and MCP envelopes inside the transport's - // fixed frame ceiling. The old 64-million-pixel limit allowed a 256 MiB - // raster and a 341 MiB base64 string, only for protocol encoding to reject - // the result against its 16 MiB frame limit after all that work was done. - const FRAME_ENVELOPE_RESERVE: usize = 64 * 1024; - const MAX_BASE64_BYTES: usize = - blitz_control_protocol::MAX_DEBUG_FRAME_BYTES - FRAME_ENVELOPE_RESERVE; - const MAX_RAW_BYTES: usize = (MAX_BASE64_BYTES / 4) * 3; - const MAX_PIXELS: u64 = (MAX_RAW_BYTES / 4) as u64; - if u64::from(width) * u64::from(height) > MAX_PIXELS { - return Err(debug_error( - "captureTooLarge", - &format!( - "{width}x{height} cannot fit in one diagnostic frame; capture a node or lower the scale" - ), - )); - } - - let surface = surface.get_or_insert_with(|| CaptureSurface::new(width, height)); - surface.size_to(width, height); - // `ImageRenderer` retains its scene between calls. A capture is a complete - // frame, not an incremental paint, so carrying the previous command list - // forward duplicates every shape and makes each sample slower than the - // last. Keep reusable renderer resources, but always begin with an empty - // scene. - surface.renderer.reset(); - let mut document = script_document.inner_mut(); - surface.renderer.render_to_vec( - |scene| { - if node_id.is_some() { - blitz_paint::paint_scene_region( - scene, - &mut document, - blitz_paint::PaintRegion::crop( - f64::from(scale), - f64::from(left) / f64::from(scale), - f64::from(top) / f64::from(scale), - width, - height, - ), - ); - } else { - blitz_paint::paint_scene( - scene, - &mut document, - f64::from(scale), - width, - height, - 0, - 0, - ); - } - }, - &mut surface.rgba, - ); - - Ok(blitz_control_protocol::CapturedImage { - width, - height, - rgba_base64: base64::engine::general_purpose::STANDARD.encode(&surface.rgba), - node_id, - }) -} - -/// Collect the same typed diagnostic snapshot from a standalone Blitz document -/// that the windowed runtime exposes over its control socket. -/// -/// Headless component hosts own a `ScriptDocument` without a Tauri event loop. -/// Keeping snapshot collection here gives those hosts the renderer's real DOM, -/// layout and computed paint data instead of a partial or reimplemented view. -#[cfg(all(feature = "diagnostics", unix))] -pub fn snapshot_document( - document: &mut ScriptDocument, - request: SnapshotRequest, - revision: u64, -) -> Result { - let started = std::time::Instant::now(); - let poll_started = std::time::Instant::now(); - let mut polls = 0u64; - for _ in 0..100 { - polls += 1; - if !document.poll(None) { - break; - } - } - let poll_ms = poll_started.elapsed().as_secs_f64() * 1_000.0; - // This forces a style and layout pass so the snapshot reports current - // geometry. It is work the observer caused, so it is reported as snapshot - // cost, never as the cost of a frame the application drew. - let resolve_started = std::time::Instant::now(); - document.inner_mut().resolve(0.0); - let snapshot_resolve_ms = resolve_started.elapsed().as_secs_f64() * 1_000.0; - let inner = document.inner(); - let layout_node_limit = inner.tree().iter().count(); - let active_element = inner.get_focussed_node_id().map(|id| id.as_u64()); - // Once for the whole snapshot: the question a control asks is "which label - // points at me", and answering it from the control costs a document scan - // each time. - let labels = LabelIndex::build(&inner); - let nodes: Vec = inner - .tree() - .iter() - .filter_map(|(id, node)| { - if !request.node_ids.is_empty() && !request.node_ids.contains(&id.as_u64()) { - return None; - } - let element = node.element_data()?; - if !dom_chain_is_attached(&inner, id, layout_node_limit) - || !layout_chain_is_valid(&inner, id, layout_node_limit) - { - return None; - } - let rect = inner.get_client_bounding_rect(id); - let visible = node_is_visible(&inner, id) - && rect - .as_ref() - .is_some_and(|rect| rect.width > 0.0 && rect.height > 0.0); - let role = semantic_role(element); - let value = if role == "generic" { - Some( - element - .attrs() - .iter() - .map(|attribute| format!("{}={}", attribute.name.local, attribute.value)) - .collect::>() - .join(" "), - ) - } else { - semantic_value(element) - }; - Some(SemanticNode { - dom_id: element_attr(element, "id").map(str::to_owned), - id: id.as_u64(), - parent: semantic_parent(&inner, id, None).map(|id| id.as_u64()), - name: semantic_name(element, node, &role, &inner, id, &labels), - role, - value, - enabled: element_attr(element, "disabled").is_none() - && element_attr(element, "aria-disabled") != Some("true"), - visible, - selected: semantic_selected(element), - bounds: rect.and_then(|rect| { - let bounds = [rect.x, rect.y, rect.width, rect.height]; - bounds - .iter() - .all(|value| value.is_finite()) - .then_some(bounds) - }), - slot: element_attr(element, "data-slot").map(str::to_owned), - }) - }) - .collect(); - let total_ms = started.elapsed().as_secs_f64() * 1_000.0; - // The runtime keeps one counter and stamps it onto all four revision - // fields. Style, layout and paint are not versioned independently - // anywhere in blitz, so four copies of one number would claim a - // resolution that does not exist. Report the counter once, as the - // document revision, and leave the rest at zero. - let revisions = RevisionSet { - document: revision, - style: 0, - layout: 0, - paint: 0, - }; - // Real per-frame timings, published by blitz-shell from `View::redraw`. - // These describe frames the application actually presented. Everything - // measured inside this function describes the snapshot collection instead, - // and is reported under `snapshot` so the two never get mixed up again. - let frame_stats = blitz_shell::latest_frame_stats(); - let metrics = RendererMetrics { - revisions: revisions.clone(), - queue_depth: None, - invalidations_coalesced: polls.saturating_sub(1), - frame: frame_stats.as_ref().map(|stats| FrameMetrics { - input_to_present_ms: None, - style_ms: None, - layout_ms: None, - resolve_ms: stats.latest.resolve_ms, - scene_ms: stats.latest.paint_ms, - submit_ms: None, - present_ms: None, - renderer_ms: stats.latest.renderer_ms, - total_ms: stats.latest.total_ms, - age_ms: stats.latest.age_ms, - }), - frame_window: frame_stats.as_ref().map(|stats| FrameWindowMetrics { - frames_total: stats.frames_total, - window_frames: stats.window_frames, - resolve: timing_stats(stats.resolve), - scene: timing_stats(stats.paint), - renderer: timing_stats(stats.renderer), - total: timing_stats(stats.frame_total), - interval: timing_stats(stats.interval), - active_fps: stats.active_fps, - missed_refreshes: stats.missed_refreshes, - display_refresh_hz: stats.display_refresh_hz, - }), - snapshot: Some(SnapshotCost { - poll_ms, - resolve_ms: snapshot_resolve_ms, - total_ms, - }), - // The other half of a frame. Everything above this line is the - // engine; this is the language runtime the application actually - // spends its time in. - script: blitz_script::script_stats::latest_script_stats().map(|stats| ScriptMetrics { - mean_ms: stats.mean_ms, - p95_ms: stats.p95_ms, - max_ms: stats.max_ms, - window_polls: stats.window_polls, - total_polls: stats.total_polls, - productive_polls: stats.productive_polls, - spent_ms: stats.spent_ms, - breakdown: blitz_script::script_stats::work_breakdown() - .into_iter() - .take(12) - .map(|(label, calls, total_ms, worst_ms)| ScriptSource { - label, - calls, - total_ms, - worst_ms, - }) - .collect(), - }), - resident_bytes: resident_bytes(), - }; - let dom = request - .include_dom - .then(|| serde_json::to_value(&nodes).unwrap_or(serde_json::Value::Null)); - let layout = request.include_layout.then(|| { - nodes - .iter() - .filter_map(|node| diagnostic_layout_row(&inner, node)) - .collect() - }); - /* - * Resolved colours, folded into the layout rows. - * - * This used to answer `computedStyleUnavailable`, which left one class - * of bug unanswerable from outside: an element whose *declared* colour - * is correct and whose *painted* colour is not. Reading the stylesheet - * cannot settle that - the cascade, the custom-property chain and the - * `@supports` gating all sit between the two - and neither can a DOM - * test environment, which has no cascade at all. - * - * Only the four that decide legibility, rather than a full style dump: - * a snapshot of every longhand for 4,500 nodes is megabytes of JSON - * nobody reads, and these are what a "why is this text invisible" - * question actually needs. - */ - let computed_style = request.include_computed_style.then(|| { - serde_json::Value::Array( - nodes - .iter() - .filter_map(|node| diagnostic_style_row(&inner, node)) - .collect(), - ) - }); - Ok(DebugSnapshot { - revisions, - active_window: Some("blitz-main".into()), - active_element, - dom, - layout, - computed_style, - metrics, - }) -} - -#[cfg(all(feature = "agent-control", unix))] -pub(crate) fn element_attr<'a>(element: &'a blitz_dom::ElementData, name: &str) -> Option<&'a str> { - element - .attrs() - .iter() - .find(|attribute| attribute.name.local.as_ref() == name) - // `as_ref`, not `as_str`. Attribute values are an interned atom as of - // ps-blitz-dom 0.3.0-beta.11, and `str::as_str` is still unstable, so - // `as_str` here resolved to the nightly-only inherent method and - // failed to build on stable. `as_ref` borrows the atom as a `&str`, - // which is what this signature returns. - .map(|attribute| attribute.value.as_ref()) -} - -#[cfg(all(feature = "agent-control", unix))] -pub(crate) fn semantic_role(element: &blitz_dom::ElementData) -> String { - if let Some(role) = element_attr(element, "role") { - return role.into(); - } - let tag = element.name.local.as_ref(); - match tag { - "a" if element_attr(element, "href").is_some() => "link", - "button" => "button", - "textarea" => "textbox", - "select" => "combobox", - "option" => "option", - "img" => "img", - "nav" => "navigation", - "main" => "main", - "form" => "form", - "ul" | "ol" => "list", - "li" => "listitem", - "table" => "table", - "tr" => "row", - "td" | "th" => "cell", - "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "heading", - "input" => match element_attr(element, "type").unwrap_or("text") { - "checkbox" => "checkbox", - "radio" => "radio", - "button" | "submit" | "reset" => "button", - "range" => "slider", - _ => "textbox", - }, - _ => "generic", - } - .into() -} - -/// Where the labels are, so a control can be asked what names it. -/// -/// # Why this exists -/// -/// A name was computed from `aria-label`, `alt` and `title` and from nothing -/// else, so the ordinary way to label a form control -- a `