use blitz_dom::Document;
use blitz_dom::DocumentConfig;
use blitz_script::{DefaultScriptFetcher, FetchError, ScriptDocument, ScriptFetcher};
use brotli::Decompressor;
use std::fs;
use std::io::Read;
use url::Url;
fn trace(message: &str) {
eprintln!("qa-inspect-host: {message}");
}
struct DistScriptFetcher {
url: String,
javascript: String,
}
impl ScriptFetcher for DistScriptFetcher {
fn fetch(&self, url: &Url) -> Result<String, FetchError> {
if url.as_str() == self.url {
Ok(self.javascript.clone())
} else {
DefaultScriptFetcher.fetch(url)
}
}
}
fn decompress_utf8(compressed: &[u8], label: &str) -> Result<String, String> {
let mut decoder = Decompressor::new(compressed, 4096);
let mut decoded = String::new();
decoder
.read_to_string(&mut decoded)
.map_err(|error| format!("could not decompress embedded {label}: {error}"))?;
Ok(decoded)
}
fn create_dist_document(dist: &std::path::Path, url: &str) -> Result<ScriptDocument, String> {
fn asset_url<'a>(html: &'a str, attribute: &str) -> Result<&'a str, String> {
let marker = format!("{attribute}=\"");
let start = html
.find(&marker)
.map(|index| index + marker.len())
.ok_or_else(|| format!("the page has no {attribute} asset"))?;
let end = html[start..]
.find('"')
.map(|index| start + index)
.ok_or_else(|| format!("the page has an unterminated {attribute} asset"))?;
Ok(&html[start..end])
}
fn read_brotli_asset(dist: &std::path::Path, url: &str, label: &str) -> Result<String, String> {
let relative = url.split('?').next().unwrap_or(url).trim_start_matches('/');
let path = dist.join(relative);
let bytes = fs::read(&path)
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
match decompress_utf8(&bytes, label) {
Ok(text) => Ok(text),
Err(compressed_error) => String::from_utf8(bytes).map_err(|_| compressed_error),
}
}
let (page_path, asset_root) = if dist.is_dir() {
(dist.join("index.html"), dist.to_path_buf())
} else {
let parent = dist
.parent()
.ok_or_else(|| format!("{} has no parent directory", dist.display()))?;
(dist.to_path_buf(), parent.to_path_buf())
};
let dist = asset_root.as_path();
trace(&format!("loading page: {}", page_path.display()));
let index = fs::read_to_string(&page_path)
.map_err(|error| format!("could not read {}: {error}", page_path.display()))?;
let javascript_url = asset_url(&index, "src")?;
let stylesheet_url = asset_url(&index, "href")?;
let css = read_brotli_asset(dist, stylesheet_url, "external CSS")?;
let javascript = read_brotli_asset(dist, javascript_url, "external JavaScript")?;
let theme = index
.find("data-theme=\"")
.map(|start| start + "data-theme=\"".len())
.and_then(|start| {
index[start..]
.find('"')
.map(|end| &index[start..start + end])
})
.unwrap_or("dark");
let html = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><style>{css}</style></head><body data-theme=\"{theme}\"><div id=\"root\"></div><script src=\"{javascript_url}\"></script></body></html>"
);
let base_url = Url::parse(url).map_err(|error| format!("invalid base URL: {error}"))?;
let script_url = base_url
.join(javascript_url)
.map_err(|error| format!("invalid JavaScript asset URL: {error}"))?
.to_string();
let config = DocumentConfig {
base_url: Some(url.into()),
..DocumentConfig::default()
};
Ok(
ScriptDocument::from_html(&html, config).with_fetcher(DistScriptFetcher {
url: script_url,
javascript,
}),
)
}
pub fn serve() -> Result<(), String> {
use blitz_traits::events::{BlitzImeEvent, UiEvent};
use blitz_traits::shell::{ColorScheme, Viewport};
use std::sync::mpsc;
#[cfg(feature = "diagnostics")]
use tauri_runtime_blitz::control_protocol::DiagnosticsRequest;
use tauri_runtime_blitz::control_protocol::{
AgentAction, AgentControlRequest, DebugError, DebugEvent, DebugResponse, InputCommand,
KeyPhase,
};
use tauri_runtime_blitz::{
AgentControlServer, ControlBridgeRequest, DocumentCapture, click_agent_node,
focus_agent_node, hover_agent_node, inspect_document, press_agent_key, snapshot_document,
};
fn dimension(variable: &str, default: u32) -> u32 {
std::env::var(variable)
.ok()
.and_then(|value| value.parse::<u32>().ok())
.filter(|value| *value > 0)
.unwrap_or(default)
}
fn settle_immediate(document: &mut ScriptDocument, clock: &std::time::Instant) -> bool {
let before = document.inner().paint_damage().generation;
for _ in 0..8 {
document.eval("void 0");
if !document.poll(None) {
break;
}
}
document.inner_mut().resolve(clock.elapsed().as_secs_f64());
document.inner().paint_damage().generation != before
}
fn commit_render(events: &tokio::sync::watch::Sender<Option<DebugEvent>>, revision: &mut u64) {
*revision = revision.saturating_add(1);
events.send_replace(Some(DebugEvent::PaintCommitted {
revision: *revision,
}));
}
let width = dimension("QA_HOST_WIDTH", 1344);
let height = dimension("QA_HOST_HEIGHT", 900);
trace("inspection host started");
let dist = std::env::var_os("QA_INSPECT_PAGE")
.ok_or_else(|| "QA_INSPECT_PAGE is not set; point it at one built page".to_owned())?;
let mut document = create_dist_document(std::path::Path::new(&dist), "tauri://localhost/")?;
document
.inner_mut()
.set_viewport(Viewport::new(width, height, 1.0, ColorScheme::Dark));
document.inner_mut().set_paint_damage_tracking(true);
document.execute_scripts();
let animation_clock = std::time::Instant::now();
settle_immediate(&mut document, &animation_clock);
trace("document ready");
let (request_tx, request_rx) =
mpsc::channel::<(ControlBridgeRequest, std::sync::mpsc::Sender<DebugResponse>)>();
let bridge: tauri_runtime_blitz::ControlBridge = std::sync::Arc::new(move |request| {
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
let (reply_tx, reply_rx) = mpsc::channel();
if request_tx.send((request, reply_tx)).is_ok()
&& let Ok(reply) = reply_rx.recv()
{
let _ = response_tx.send(reply);
return response_rx;
}
let _ = response_tx.send(DebugResponse::Error(DebugError {
code: "documentUnavailable".into(),
message: "the document is no longer serving".into(),
}));
response_rx
});
let (render_events, render_event_receiver) = tokio::sync::watch::channel(None);
let server = AgentControlServer::start_with_events(bridge, render_event_receiver)
.map_err(|error| format!("could not host the control socket: {error}"))?;
trace(&format!(
"inspection socket listening: {}",
server.descriptor_path().display()
));
println!("{}", server.descriptor_path().display());
use std::io::Write as _;
let _ = std::io::stdout().flush();
let mut revision = 0_u64;
let mut render_revision = 0_u64;
#[cfg(feature = "diagnostics")]
let mut capture = DocumentCapture::new();
'serve: while let Ok((request, reply)) = request_rx.recv() {
let mut painted = false;
let response = match request {
ControlBridgeRequest::Agent(request) => match request {
AgentControlRequest::Inspect { root, max_depth } => {
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(()) => {
painted = settle_immediate(&mut document, &animation_clock);
DebugResponse::Ack
}
Err(error) => DebugResponse::Error(error),
}
}
AgentControlRequest::Act(AgentAction::Click { node_id }) => {
match click_agent_node(&mut document, node_id, 1) {
Ok(_) => {
painted = settle_immediate(&mut document, &animation_clock);
DebugResponse::Ack
}
Err(error) => DebugResponse::Error(error),
}
}
AgentControlRequest::Act(AgentAction::ScrollIntoView { .. }) => {
DebugResponse::Ack
}
AgentControlRequest::Act(AgentAction::Hover { node_id }) => {
match hover_agent_node(&mut document, node_id) {
Ok(_) => {
painted = settle_immediate(&mut document, &animation_clock);
DebugResponse::Ack
}
Err(error) => DebugResponse::Error(error),
}
}
AgentControlRequest::Act(AgentAction::DoubleClick { node_id }) => {
match click_agent_node(&mut document, node_id, 2) {
Ok(_) => {
painted = settle_immediate(&mut document, &animation_clock);
DebugResponse::Ack
}
Err(error) => DebugResponse::Error(error),
}
}
AgentControlRequest::Act(AgentAction::SetValue { node_id, value }) => {
let node_id = blitz_dom::NodeId::from_u64(node_id);
if !document
.inner()
.get_node(node_id)
.and_then(|node| node.element_data())
.is_some_and(|element| element.text_input_data().is_some())
{
DebugResponse::Error(DebugError {
code: "notEditable".into(),
message: "node is not a text input".into(),
})
} else {
document.inner_mut().set_focus_to(node_id);
document
.inner_mut()
.with_text_input(node_id, |mut editor| editor.select_all());
document.handle_ui_event(UiEvent::Ime(BlitzImeEvent::Commit(value)));
painted = settle_immediate(&mut document, &animation_clock);
DebugResponse::Ack
}
}
AgentControlRequest::Act(AgentAction::Input(InputCommand::Key {
key,
code,
phase,
..
})) => {
if matches!(phase, KeyPhase::Up) {
DebugResponse::Ack
} else {
match press_agent_key(&mut document, &key, &code) {
Ok(()) => {
painted = settle_immediate(&mut document, &animation_clock);
DebugResponse::Ack
}
Err(error) => DebugResponse::Error(error),
}
}
}
AgentControlRequest::Quit => {
let _ = reply.send(DebugResponse::Ack);
break 'serve;
}
_ => DebugResponse::Error(DebugError {
code: "unsupported".into(),
message: "this host serves Inspect, Focus, Hover, Click, SetValue and Key only"
.into(),
}),
},
#[cfg(feature = "diagnostics")]
ControlBridgeRequest::Diagnostics(DiagnosticsRequest::Capture(request)) => {
match capture.capture(&mut document, request) {
Ok(captured) => DebugResponse::Captured(captured),
Err(error) => DebugResponse::Error(error),
}
}
#[cfg(feature = "diagnostics")]
ControlBridgeRequest::Diagnostics(DiagnosticsRequest::Snapshot(request)) => {
revision += 1;
match snapshot_document(&mut document, request, revision) {
Ok(snapshot) => DebugResponse::Snapshot(snapshot),
Err(error) => DebugResponse::Error(error),
}
}
#[cfg(feature = "diagnostics")]
ControlBridgeRequest::Diagnostics(_) => DebugResponse::Error(DebugError {
code: "unsupported".into(),
message: "the headless host serves diagnostics Capture and Snapshot only".into(),
}),
};
if reply.send(response).is_err() {
break;
}
if painted {
commit_render(&render_events, &mut render_revision);
}
}
trace("inspection host finished");
Ok(())
}