1use blitz_dom::Document;
35use blitz_dom::DocumentConfig;
36use blitz_script::{DefaultScriptFetcher, FetchError, ScriptDocument, ScriptFetcher};
37use brotli::Decompressor;
38use std::fs;
39use std::io::Read;
40use std::path::{Component, Path, PathBuf};
41use url::Url;
42
43const MAX_DECOMPRESSED_ASSET_BYTES: u64 = 32 * 1024 * 1024;
44
45fn trace(message: &str) {
46 eprintln!("qa-inspect-host: {message}");
47}
48
49struct DistScriptFetcher {
50 url: String,
51 javascript: String,
52}
53
54impl ScriptFetcher for DistScriptFetcher {
55 fn fetch(&self, url: &Url) -> Result<String, FetchError> {
56 if url.as_str() == self.url {
57 Ok(self.javascript.clone())
58 } else {
59 DefaultScriptFetcher.fetch(url)
60 }
61 }
62}
63
64fn decompress_utf8(compressed: &[u8], label: &str) -> Result<String, String> {
65 let mut decoder = Decompressor::new(compressed, 4096);
66 let mut decoded = Vec::new();
67 decoder
68 .by_ref()
69 .take(MAX_DECOMPRESSED_ASSET_BYTES + 1)
70 .read_to_end(&mut decoded)
71 .map_err(|error| format!("could not decompress embedded {label}: {error}"))?;
72 if decoded.len() as u64 > MAX_DECOMPRESSED_ASSET_BYTES {
73 return Err(format!(
74 "decompressed {label} exceeds the {} MiB safety limit",
75 MAX_DECOMPRESSED_ASSET_BYTES / (1024 * 1024)
76 ));
77 }
78 String::from_utf8(decoded)
79 .map_err(|error| format!("decompressed {label} is not UTF-8: {error}"))
80}
81
82fn asset_path(root: &Path, reference: &str) -> Result<PathBuf, String> {
83 let reference = reference.split('?').next().unwrap_or(reference);
84 let relative = Path::new(reference.trim_start_matches('/'));
85 if relative.components().any(|component| {
86 matches!(
87 component,
88 Component::ParentDir | Component::RootDir | Component::Prefix(_)
89 )
90 }) {
91 return Err(format!(
92 "asset path escapes the page directory: {reference:?}"
93 ));
94 }
95 let canonical_root = fs::canonicalize(root)
96 .map_err(|error| format!("could not resolve asset root {}: {error}", root.display()))?;
97 let candidate = fs::canonicalize(canonical_root.join(relative)).map_err(|error| {
98 format!(
99 "could not resolve asset {} below {}: {error}",
100 relative.display(),
101 canonical_root.display()
102 )
103 })?;
104 if !candidate.starts_with(&canonical_root) {
105 return Err(format!(
106 "asset path escapes the page directory: {reference:?}"
107 ));
108 }
109 Ok(candidate)
110}
111
112fn create_dist_document(dist: &std::path::Path, url: &str) -> Result<ScriptDocument, String> {
113 fn asset_url<'a>(html: &'a str, attribute: &str) -> Result<&'a str, String> {
114 let marker = format!("{attribute}=\"");
115 let start = html
116 .find(&marker)
117 .map(|index| index + marker.len())
118 .ok_or_else(|| format!("the page has no {attribute} asset"))?;
119 let end = html[start..]
120 .find('"')
121 .map(|index| start + index)
122 .ok_or_else(|| format!("the page has an unterminated {attribute} asset"))?;
123 Ok(&html[start..end])
124 }
125
126 fn read_brotli_asset(dist: &std::path::Path, url: &str, label: &str) -> Result<String, String> {
137 let path = asset_path(dist, url)?;
138 let bytes = fs::read(&path)
139 .map_err(|error| format!("could not read {}: {error}", path.display()))?;
140 match decompress_utf8(&bytes, label) {
141 Ok(text) => Ok(text),
142 Err(compressed_error) => String::from_utf8(bytes).map_err(|_| compressed_error),
143 }
144 }
145
146 let (page_path, asset_root) = if dist.is_dir() {
160 (dist.join("index.html"), dist.to_path_buf())
161 } else {
162 let parent = dist
163 .parent()
164 .ok_or_else(|| format!("{} has no parent directory", dist.display()))?;
165 (dist.to_path_buf(), parent.to_path_buf())
166 };
167 let dist = asset_root.as_path();
168
169 trace(&format!("loading page: {}", page_path.display()));
170 let index = fs::read_to_string(&page_path)
171 .map_err(|error| format!("could not read {}: {error}", page_path.display()))?;
172 let Ok(javascript_url) = asset_url(&index, "src") else {
193 trace("no external bundle; serving the page as written");
194 let config = DocumentConfig {
195 base_url: Some(url.into()),
196 ..DocumentConfig::default()
197 };
198 return Ok(ScriptDocument::from_html(&index, config));
199 };
200
201 let stylesheet_url = asset_url(&index, "href")?;
202 let css = read_brotli_asset(dist, stylesheet_url, "external CSS")?;
203 let javascript = read_brotli_asset(dist, javascript_url, "external JavaScript")?;
204 let theme = index
212 .find("data-theme=\"")
213 .map(|start| start + "data-theme=\"".len())
214 .and_then(|start| {
215 index[start..]
216 .find('"')
217 .map(|end| &index[start..start + end])
218 })
219 .unwrap_or("dark");
220 let html = format!(
221 "<!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>"
222 );
223 let base_url = Url::parse(url).map_err(|error| format!("invalid base URL: {error}"))?;
224 let script_url = base_url
225 .join(javascript_url)
226 .map_err(|error| format!("invalid JavaScript asset URL: {error}"))?
227 .to_string();
228 let config = DocumentConfig {
229 base_url: Some(url.into()),
230 ..DocumentConfig::default()
231 };
232 Ok(
233 ScriptDocument::from_html(&html, config).with_fetcher(DistScriptFetcher {
234 url: script_url,
235 javascript,
236 }),
237 )
238}
239
240pub fn serve() -> Result<(), String> {
241 use blitz_traits::events::{BlitzImeEvent, UiEvent};
242 use blitz_traits::shell::{ColorScheme, Viewport};
243 use std::sync::mpsc;
244 #[cfg(feature = "diagnostics")]
245 use tauri_runtime_blitz::control_protocol::DiagnosticsRequest;
246 use tauri_runtime_blitz::control_protocol::{
247 AgentAction, AgentControlRequest, DebugError, DebugEvent, DebugResponse, InputCommand,
248 KeyPhase,
249 };
250 use tauri_runtime_blitz::{
251 AgentControlServer, ControlBridgeRequest, DocumentCapture, click_agent_node,
252 focus_agent_node, hover_agent_node, inspect_document, press_agent_key, snapshot_document,
253 };
254
255 fn dimension(variable: &str, default: u32) -> Result<u32, String> {
256 let Some(value) = std::env::var_os(variable) else {
257 return Ok(default);
258 };
259 let text = value
260 .into_string()
261 .map_err(|_| format!("{variable} is not valid UTF-8"))?;
262 text.parse::<u32>()
263 .ok()
264 .filter(|value| *value > 0)
265 .ok_or_else(|| format!("{variable} must be a positive integer, got {text:?}"))
266 }
267
268 struct SettleFailure {
273 error: DebugError,
274 painted: bool,
275 }
276
277 fn settle_immediate(
278 document: &mut ScriptDocument,
279 clock: &std::time::Instant,
280 deadline: std::time::Duration,
281 ) -> Result<bool, SettleFailure> {
282 let before = document.inner().paint_damage().generation;
283 let started = std::time::Instant::now();
284 let mut iterations = 0_u32;
285 loop {
286 if !document.poll(None) {
287 break;
288 }
289 iterations = iterations.saturating_add(1);
290 if started.elapsed() >= deadline {
291 document.inner_mut().resolve(clock.elapsed().as_secs_f64());
292 return Err(SettleFailure {
293 painted: document.inner().paint_damage().generation != before,
294 error: DebugError {
295 code: "documentNotQuiescent".into(),
296 message: format!(
297 "the document still had immediate work after {iterations} settle iterations and {}ms",
298 deadline.as_millis()
299 ),
300 },
301 });
302 }
303 }
304 document.inner_mut().resolve(clock.elapsed().as_secs_f64());
305 Ok(document.inner().paint_damage().generation != before)
306 }
307
308 fn settle_response(
309 document: &mut ScriptDocument,
310 clock: &std::time::Instant,
311 deadline: std::time::Duration,
312 painted: &mut bool,
313 ) -> DebugResponse {
314 match settle_immediate(document, clock, deadline) {
315 Ok(did_paint) => {
316 *painted = did_paint;
317 DebugResponse::Ack
318 }
319 Err(failure) => {
320 *painted = failure.painted;
321 DebugResponse::Error(failure.error)
322 }
323 }
324 }
325
326 fn commit_render(events: &tokio::sync::watch::Sender<Option<DebugEvent>>, revision: &mut u64) {
327 *revision = revision.saturating_add(1);
328 events.send_replace(Some(DebugEvent::PaintCommitted {
329 revision: *revision,
330 }));
331 }
332
333 let width = dimension("QA_HOST_WIDTH", 1344)?;
334 let height = dimension("QA_HOST_HEIGHT", 900)?;
335 let settle_deadline =
336 std::time::Duration::from_millis(u64::from(dimension("QA_HOST_SETTLE_MS", 100)?));
337
338 trace("inspection host started");
339 let dist = std::env::var_os("QA_INSPECT_PAGE")
340 .ok_or_else(|| "QA_INSPECT_PAGE is not set; point it at one built page".to_owned())?;
341 let mut document = create_dist_document(std::path::Path::new(&dist), "tauri://localhost/")?;
342 document
343 .inner_mut()
344 .set_viewport(Viewport::new(width, height, 1.0, ColorScheme::Dark));
345 document.inner_mut().set_paint_damage_tracking(true);
346 document.execute_scripts();
347
348 let animation_clock = std::time::Instant::now();
351 if let Err(failure) = settle_immediate(&mut document, &animation_clock, settle_deadline) {
352 trace(&format!(
353 "initial document reached the settle deadline: {}",
354 failure.error.message
355 ));
356 }
357 trace("document ready");
358
359 const MAX_PENDING_REQUESTS: usize = 64;
367 let (request_tx, request_rx) = mpsc::sync_channel::<(
368 ControlBridgeRequest,
369 tokio::sync::oneshot::Sender<DebugResponse>,
370 )>(MAX_PENDING_REQUESTS);
371
372 let bridge: tauri_runtime_blitz::ControlBridge = std::sync::Arc::new(move |request| {
373 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
374 match request_tx.try_send((request, response_tx)) {
375 Ok(()) => response_rx,
376 Err(mpsc::TrySendError::Full((_, response_tx))) => {
377 let _ = response_tx.send(DebugResponse::Error(DebugError {
378 code: "documentBusy".into(),
379 message: format!(
380 "the document already has {MAX_PENDING_REQUESTS} pending inspection requests"
381 ),
382 }));
383 response_rx
384 }
385 Err(mpsc::TrySendError::Disconnected((_, response_tx))) => {
386 let _ = response_tx.send(DebugResponse::Error(DebugError {
387 code: "documentUnavailable".into(),
388 message: "the document is no longer serving".into(),
389 }));
390 response_rx
391 }
392 }
393 });
394
395 let (render_events, render_event_receiver) = tokio::sync::watch::channel(None);
396 let server = AgentControlServer::start_with_events(bridge, render_event_receiver)
397 .map_err(|error| format!("could not host the control socket: {error}"))?;
398 trace(&format!(
399 "inspection socket listening: {}",
400 server.descriptor_path().display()
401 ));
402 println!("{}", server.descriptor_path().display());
406 use std::io::Write as _;
407 let _ = std::io::stdout().flush();
408
409 let mut revision = 0_u64;
410 let mut render_revision = 0_u64;
411 #[cfg(feature = "diagnostics")]
412 let mut capture = DocumentCapture::new();
413 while let Ok((request, reply)) = request_rx.recv() {
414 let mut painted = false;
415 let response = match request {
416 ControlBridgeRequest::Agent(request) => match request {
417 AgentControlRequest::Inspect { root, max_depth } => {
418 revision += 1;
419 inspect_document(&mut document, root, max_depth, revision)
420 }
421 AgentControlRequest::Act(AgentAction::Focus { node_id }) => {
422 let node_id = blitz_dom::NodeId::from_u64(node_id);
423 match focus_agent_node(&mut document, node_id) {
424 Ok(()) => settle_response(
425 &mut document,
426 &animation_clock,
427 settle_deadline,
428 &mut painted,
429 ),
430 Err(error) => DebugResponse::Error(error),
431 }
432 }
433 AgentControlRequest::Act(AgentAction::Click { node_id }) => {
434 match click_agent_node(&mut document, node_id, 1) {
435 Ok(_) => settle_response(
436 &mut document,
437 &animation_clock,
438 settle_deadline,
439 &mut painted,
440 ),
441 Err(error) => DebugResponse::Error(error),
442 }
443 }
444 AgentControlRequest::Act(AgentAction::ScrollIntoView { .. }) => {
445 DebugResponse::Ack
456 }
457 AgentControlRequest::Act(AgentAction::Hover { node_id }) => {
458 match hover_agent_node(&mut document, node_id) {
465 Ok(_) => settle_response(
466 &mut document,
467 &animation_clock,
468 settle_deadline,
469 &mut painted,
470 ),
471 Err(error) => DebugResponse::Error(error),
472 }
473 }
474 AgentControlRequest::Act(AgentAction::DoubleClick { node_id }) => {
475 match click_agent_node(&mut document, node_id, 2) {
476 Ok(_) => settle_response(
477 &mut document,
478 &animation_clock,
479 settle_deadline,
480 &mut painted,
481 ),
482 Err(error) => DebugResponse::Error(error),
483 }
484 }
485 AgentControlRequest::Act(AgentAction::SetValue { node_id, value }) => {
486 let node_id = blitz_dom::NodeId::from_u64(node_id);
487 if !document
488 .inner()
489 .get_node(node_id)
490 .and_then(|node| node.element_data())
491 .is_some_and(|element| element.text_input_data().is_some())
492 {
493 DebugResponse::Error(DebugError {
494 code: "notEditable".into(),
495 message: "node is not a text input".into(),
496 })
497 } else {
498 document.inner_mut().set_focus_to(node_id);
499 document
500 .inner_mut()
501 .with_text_input(node_id, |mut editor| editor.select_all());
502 document.handle_ui_event(UiEvent::Ime(BlitzImeEvent::Commit(value)));
503 settle_response(
504 &mut document,
505 &animation_clock,
506 settle_deadline,
507 &mut painted,
508 )
509 }
510 }
511 AgentControlRequest::Act(AgentAction::Input(InputCommand::Key {
512 key,
513 code,
514 phase,
515 ..
516 })) => {
517 if matches!(phase, KeyPhase::Up) {
527 DebugResponse::Ack
528 } else {
529 match press_agent_key(&mut document, &key, &code) {
530 Ok(()) => settle_response(
531 &mut document,
532 &animation_clock,
533 settle_deadline,
534 &mut painted,
535 ),
536 Err(error) => DebugResponse::Error(error),
537 }
538 }
539 }
540 _ => DebugResponse::Error(DebugError {
544 code: "unsupported".into(),
545 message: "this host serves Inspect, Focus, Hover, Click, DoubleClick, SetValue and Key only".into(),
546 }),
547 },
548 #[cfg(feature = "diagnostics")]
549 ControlBridgeRequest::Diagnostics(DiagnosticsRequest::Capture(request)) => {
550 if !request.scale.is_finite() || !(0.25..=8.0).contains(&request.scale) {
551 DebugResponse::Error(DebugError {
552 code: "invalidArgument".into(),
553 message: "capture scale must be finite and between 0.25 and 8".into(),
554 })
555 } else {
556 match capture.capture(&mut document, request) {
557 Ok(captured) => DebugResponse::Captured(captured),
558 Err(error) => DebugResponse::Error(error),
559 }
560 }
561 }
562 #[cfg(feature = "diagnostics")]
563 ControlBridgeRequest::Diagnostics(DiagnosticsRequest::Snapshot(request)) => {
564 revision += 1;
565 match snapshot_document(&mut document, request, revision) {
566 Ok(snapshot) => DebugResponse::Snapshot(snapshot),
567 Err(error) => DebugResponse::Error(error),
568 }
569 }
570 #[cfg(feature = "diagnostics")]
571 ControlBridgeRequest::Diagnostics(DiagnosticsRequest::WindowComposition) => {
572 DebugResponse::WindowComposition(
573 tauri_runtime_blitz::control_protocol::WindowComposition::default(),
574 )
575 }
576 #[cfg(feature = "diagnostics")]
577 ControlBridgeRequest::Diagnostics(_) => DebugResponse::Error(DebugError {
578 code: "unsupported".into(),
579 message: "the headless host serves diagnostics Capture, Snapshot and WindowComposition only".into(),
580 }),
581 };
582 if painted {
583 commit_render(&render_events, &mut render_revision);
584 }
585 if reply.send(response).is_err() {
586 break;
587 }
588 }
589
590 trace("inspection host finished");
591 Ok(())
592}
593
594#[cfg(test)]
595mod tests {
596 use super::asset_path;
597
598 #[test]
599 fn assets_cannot_escape_the_page_directory() {
600 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
601 .join("../../target")
602 .join(format!(
603 "qa-host-assets-{}-{}",
604 std::process::id(),
605 std::time::SystemTime::now()
606 .duration_since(std::time::UNIX_EPOCH)
607 .expect("system clock is after the epoch")
608 .as_nanos()
609 ));
610 std::fs::create_dir(&root).expect("create fixture root");
611 std::fs::write(root.join("inside.js"), "fixture").expect("write fixture asset");
612 assert_eq!(
613 asset_path(&root, "inside.js?cache=1").expect("local asset"),
614 std::fs::canonicalize(root.join("inside.js")).unwrap()
615 );
616 assert!(asset_path(&root, "../outside.js").is_err());
617 assert!(asset_path(&root, "/../../outside.js").is_err());
618 let _ = std::fs::remove_dir_all(root);
619 }
620}