use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use frust_core::InspectNode;
use frust_core::event::{
InputEvent, Key, KeyEvent, Modifiers, PointerButton, PointerEvent, PointerPhase, ScrollDelta,
};
use frust_devtools::{AppInfo, BackendError, DevtoolsBackend, Service, ServiceHandle};
use frust_devtools_protocol::{
FrameStats, InputScrollParams, InputTapParams, MetricsSnapshot, RectPx, WidgetNode,
WidgetProps, WidgetTreeDump,
};
use kurbo::Point;
use crate::perf::FramePasses;
pub const DEVTOOLS_VAR: &str = "FRUST_DEVTOOLS";
const UI_HOP_DEADLINE: Duration = Duration::from_secs(5);
const MAX_QUEUED_REQUESTS: usize = 64;
enum UiRequest {
Tree(std::sync::mpsc::Sender<Vec<InspectNode>>),
Events(Vec<InputEvent>, std::sync::mpsc::Sender<()>),
}
struct Bridge {
queue: Mutex<VecDeque<UiRequest>>,
wake: Option<Box<dyn Fn() + Send + Sync>>,
}
impl Bridge {
fn submit(&self, request: UiRequest) -> bool {
{
let Ok(mut queue) = self.queue.lock() else {
return false;
};
if queue.len() >= MAX_QUEUED_REQUESTS {
return false;
}
queue.push_back(request);
}
if let Some(wake) = self.wake.as_ref() {
wake();
}
true
}
}
static BRIDGE: OnceLock<Arc<Bridge>> = OnceLock::new();
static SERVICE: OnceLock<ServiceHandle> = OnceLock::new();
static FRAME_N: AtomicU64 = AtomicU64::new(0);
pub trait DevtoolsUi {
fn inspect(&self) -> Vec<InspectNode>;
fn dispatch(&mut self, event: InputEvent);
}
pub fn pump(ui: &mut dyn DevtoolsUi) {
let Some(bridge) = BRIDGE.get() else {
return;
};
loop {
let request = {
let Ok(mut queue) = bridge.queue.lock() else {
return;
};
match queue.pop_front() {
Some(request) => request,
None => return,
}
};
match request {
UiRequest::Tree(reply) => {
let _ = reply.send(ui.inspect());
}
UiRequest::Events(events, reply) => {
for event in events {
ui.dispatch(event);
}
let _ = reply.send(());
}
}
}
}
pub fn start(app_name: impl Into<String>, wake: Option<Box<dyn Fn() + Send + Sync>>) {
if kill_switch_engaged(std::env::var(DEVTOOLS_VAR).ok().as_deref()) {
log::info!("frust-devtools: disabled by {DEVTOOLS_VAR}=0");
return;
}
if SERVICE.get().is_some() {
return;
}
let bridge = Arc::new(Bridge {
queue: Mutex::new(VecDeque::new()),
wake,
});
if BRIDGE.set(Arc::clone(&bridge)).is_err() {
return;
}
let backend = ShellBackend {
bridge,
started: Instant::now(),
};
let info = AppInfo::new(app_name, env!("CARGO_PKG_VERSION"));
match Service::start(backend, info) {
Ok(handle) => {
let _ = SERVICE.set(handle);
}
Err(err) => log::warn!(
"{}",
frust_devtools_protocol::format_failure_line(&err.to_string())
),
}
}
pub fn port() -> Option<u16> {
SERVICE.get().map(ServiceHandle::port)
}
pub fn token() -> Option<String> {
SERVICE
.get()
.and_then(ServiceHandle::token)
.map(str::to_string)
}
fn kill_switch_engaged(value: Option<&str>) -> bool {
value == Some("0")
}
pub fn app_name_from_process() -> String {
let raw = std::fs::read_to_string("/proc/self/cmdline")
.ok()
.and_then(|cmdline| {
cmdline
.split('\0')
.next()
.filter(|first| !first.is_empty())
.map(str::to_string)
})
.or_else(|| std::env::args().next());
raw.as_deref()
.and_then(|path| path.rsplit(['/', '\\']).next())
.filter(|name| !name.is_empty())
.unwrap_or("frust-app")
.to_string()
}
pub(crate) fn publish_frame(passes: &FramePasses) {
let Some(service) = SERVICE.get() else {
return;
};
let n = FRAME_N.fetch_add(1, Ordering::Relaxed) + 1;
service.publish_frame_stats(FrameStats {
n,
total_us: passes.total().as_micros() as u64,
rebuild_us: passes.rebuild.as_micros() as u64,
layout_us: passes.layout.as_micros() as u64,
paint_us: passes.paint.as_micros() as u64,
encode_us: passes.encode.as_micros() as u64,
acquire_us: passes.acquire.as_micros() as u64,
submit_us: passes.submit.as_micros() as u64,
skipped: passes.skipped,
});
}
struct ShellBackend {
bridge: Arc<Bridge>,
started: Instant,
}
impl ShellBackend {
fn hop<T>(
&self,
make_request: impl FnOnce(std::sync::mpsc::Sender<T>) -> UiRequest,
) -> Option<T> {
let (tx, rx) = std::sync::mpsc::channel();
if !self.bridge.submit(make_request(tx)) {
log::warn!("frust-devtools: the UI-thread request queue is full");
return None;
}
rx.recv_timeout(UI_HOP_DEADLINE).ok()
}
}
impl DevtoolsBackend for ShellBackend {
fn widget_tree(&self) -> WidgetTreeDump {
match self.hop(UiRequest::Tree) {
Some(nodes) => tree_dump(&nodes),
None => WidgetTreeDump { roots: Vec::new() },
}
}
fn widget_props(&self, id: u64) -> Option<WidgetProps> {
let nodes = self.hop(UiRequest::Tree)?;
props_for(&nodes, id)
}
fn metrics_snapshot(&self) -> MetricsSnapshot {
MetricsSnapshot {
rss_bytes: rss_bytes(),
uptime_ms: self.started.elapsed().as_millis() as u64,
}
}
fn inject_tap(&self, params: InputTapParams) -> Result<(), BackendError> {
let position = logical_point(params.x, params.y)?;
self.inject(vec![
InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position,
button: PointerButton::Primary,
}),
InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Up,
position,
button: PointerButton::Primary,
}),
])
}
fn inject_scroll(&self, params: InputScrollParams) -> Result<(), BackendError> {
let position = logical_point(params.x, params.y)?;
if !params.dx.is_finite() || !params.dy.is_finite() {
return Err(BackendError::invalid_request(
"scroll delta must be finite logical px",
));
}
self.inject(vec![InputEvent::Scroll {
position,
delta: ScrollDelta::Pixels(params.dx, params.dy),
}])
}
fn inject_text(&self, text: &str) -> Result<(), BackendError> {
if text.is_empty() {
return Err(BackendError::invalid_request("text must not be empty"));
}
self.inject(vec![InputEvent::Key(KeyEvent {
key: Key::Character(text.to_string()),
modifiers: Modifiers::default(),
repeat: false,
})])
}
}
impl ShellBackend {
fn inject(&self, events: Vec<InputEvent>) -> Result<(), BackendError> {
match self.hop(|reply| UiRequest::Events(events, reply)) {
Some(()) => Ok(()),
None => Err(BackendError::unavailable(
"the app's UI thread did not process the injected event",
)),
}
}
}
fn logical_point(x: f64, y: f64) -> Result<Point, BackendError> {
if !x.is_finite() || !y.is_finite() {
return Err(BackendError::invalid_request(
"tap/scroll position must be finite logical px",
));
}
Ok(Point::new(x, y))
}
fn rss_bytes() -> Option<u64> {
#[cfg(any(target_os = "linux", target_os = "android"))]
{
let status = std::fs::read_to_string("/proc/self/status").ok()?;
parse_vm_rss_kb(&status).map(|kb| kb * 1024)
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
None
}
}
#[cfg_attr(
not(any(target_os = "linux", target_os = "android", test)),
allow(dead_code)
)]
fn parse_vm_rss_kb(status: &str) -> Option<u64> {
status
.lines()
.find_map(|line| line.strip_prefix("VmRSS:"))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|kb| kb.parse::<u64>().ok())
}
fn tree_dump(nodes: &[InspectNode]) -> WidgetTreeDump {
let index: HashMap<u64, usize> = nodes
.iter()
.enumerate()
.map(|(slot, node)| (node.id.0, slot))
.collect();
let mut visited = HashSet::new();
let roots = nodes
.iter()
.enumerate()
.filter(|(_, node)| node.parent.is_none())
.filter_map(|(slot, _)| widget_node(nodes, &index, slot, &mut visited))
.collect();
WidgetTreeDump { roots }
}
fn widget_node(
nodes: &[InspectNode],
index: &HashMap<u64, usize>,
slot: usize,
visited: &mut HashSet<u64>,
) -> Option<WidgetNode> {
let node = nodes.get(slot)?;
if !visited.insert(node.id.0) {
return None;
}
let children = node
.children
.iter()
.filter_map(|child| index.get(&child.0).copied())
.filter_map(|child_slot| widget_node(nodes, index, child_slot, visited))
.collect();
Some(WidgetNode {
id: node.id.0,
type_name: node.type_name.to_string(),
debug_label: node.debug_label.as_ref().map(|label| label.to_string()),
bounds: Some(RectPx {
x: node.bounds.x0,
y: node.bounds.y0,
width: node.bounds.width(),
height: node.bounds.height(),
}),
children,
})
}
fn props_for(nodes: &[InspectNode], id: u64) -> Option<WidgetProps> {
let node = nodes.iter().find(|node| node.id.0 == id)?;
let mut entries = vec![("type".to_string(), node.type_name.to_string())];
if let Some(label) = node.debug_label.as_ref() {
entries.push(("debug_label".to_string(), label.to_string()));
}
entries.push(("x".to_string(), format!("{:.1}", node.bounds.x0)));
entries.push(("y".to_string(), format!("{:.1}", node.bounds.y0)));
entries.push(("width".to_string(), format!("{:.1}", node.bounds.width())));
entries.push(("height".to_string(), format!("{:.1}", node.bounds.height())));
entries.push(("depth".to_string(), node.depth.to_string()));
entries.push((
"parent".to_string(),
node.parent
.map(|parent| parent.0.to_string())
.unwrap_or_else(|| "none".to_string()),
));
entries.push(("children".to_string(), node.children.len().to_string()));
Some(WidgetProps { id, entries })
}
#[cfg(test)]
mod tests {
use super::*;
use frust_core::view::WidgetId;
use kurbo::Rect;
fn node(id: u64, parent: Option<u64>, children: &[u64], depth: usize) -> InspectNode {
InspectNode {
id: WidgetId(id),
parent: parent.map(WidgetId),
type_name: "StubWidget",
debug_label: None,
bounds: Rect::new(0.0, 0.0, 10.0, 20.0),
children: children.iter().copied().map(WidgetId).collect(),
depth,
}
}
fn fixture() -> Vec<InspectNode> {
vec![
InspectNode {
bounds: Rect::new(0.0, 0.0, 100.0, 200.0),
debug_label: Some("root".into()),
..node(1, None, &[2], 0)
},
node(2, Some(1), &[3], 1),
node(3, Some(2), &[], 2),
]
}
#[test]
fn tree_dump_nests_the_flat_pre_order_snapshot() {
let dump = tree_dump(&fixture());
assert_eq!(dump.roots.len(), 1);
let root = &dump.roots[0];
assert_eq!(root.id, 1);
assert_eq!(root.type_name, "StubWidget");
assert_eq!(root.debug_label.as_deref(), Some("root"));
assert_eq!(root.children.len(), 1);
assert_eq!(root.children[0].id, 2);
assert_eq!(root.children[0].children[0].id, 3);
assert!(root.children[0].children[0].children.is_empty());
}
#[test]
fn tree_dump_carries_absolute_bounds_in_logical_px() {
let dump = tree_dump(&fixture());
let bounds = dump.roots[0].bounds.expect("bounds always reported");
assert_eq!(bounds.x, 0.0);
assert_eq!(bounds.y, 0.0);
assert_eq!(bounds.width, 100.0);
assert_eq!(bounds.height, 200.0);
}
#[test]
fn tree_dump_of_an_empty_snapshot_is_an_empty_dump() {
assert!(tree_dump(&[]).roots.is_empty());
}
#[test]
fn tree_dump_reports_every_root_of_a_multi_root_snapshot() {
let nodes = vec![node(1, None, &[], 0), node(2, None, &[], 0)];
let dump = tree_dump(&nodes);
assert_eq!(
dump.roots.iter().map(|n| n.id).collect::<Vec<_>>(),
vec![1, 2]
);
}
#[test]
fn tree_dump_terminates_on_a_malformed_cyclic_snapshot() {
let nodes = vec![node(1, None, &[2], 0), node(2, Some(1), &[1], 1)];
let dump = tree_dump(&nodes);
assert_eq!(dump.roots.len(), 1);
assert!(dump.roots[0].children[0].children.is_empty());
}
#[test]
fn tree_dump_skips_a_child_id_with_no_node() {
let nodes = vec![node(1, None, &[2, 9], 0), node(2, Some(1), &[], 1)];
let dump = tree_dump(&nodes);
assert_eq!(dump.roots[0].children.len(), 1);
}
#[test]
fn props_for_reports_geometry_and_lineage() {
let props = props_for(&fixture(), 2).expect("node 2 is in the snapshot");
assert_eq!(props.id, 2);
let entries: HashMap<&str, &str> = props
.entries
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(entries["type"], "StubWidget");
assert_eq!(entries["width"], "10.0");
assert_eq!(entries["height"], "20.0");
assert_eq!(entries["depth"], "1");
assert_eq!(entries["parent"], "1");
assert_eq!(entries["children"], "1");
assert!(!entries.contains_key("debug_label"));
}
#[test]
fn props_for_reports_a_root_with_no_parent() {
let props = props_for(&fixture(), 1).expect("node 1 is in the snapshot");
let parent = props
.entries
.iter()
.find(|(k, _)| k == "parent")
.map(|(_, v)| v.as_str());
assert_eq!(parent, Some("none"));
}
#[test]
fn props_for_unknown_id_is_none() {
assert!(props_for(&fixture(), 999).is_none());
}
#[test]
fn a_non_finite_injection_coordinate_is_rejected() {
assert!(logical_point(f64::NAN, 0.0).is_err());
assert!(logical_point(0.0, f64::INFINITY).is_err());
assert_eq!(logical_point(3.0, 4.0).map(|p| (p.x, p.y)), Ok((3.0, 4.0)));
}
#[test]
fn kill_switch_engages_only_on_literal_zero() {
assert!(kill_switch_engaged(Some("0")));
assert!(!kill_switch_engaged(None));
assert!(!kill_switch_engaged(Some("1")));
assert!(!kill_switch_engaged(Some("")));
}
#[test]
fn vm_rss_is_parsed_in_kilobytes() {
let status = "Name:\tapp\nVmPeak:\t 100 kB\nVmRSS:\t 4096 kB\nThreads:\t3\n";
assert_eq!(parse_vm_rss_kb(status), Some(4096));
}
#[test]
fn vm_rss_absent_is_none() {
assert_eq!(parse_vm_rss_kb("Name:\tapp\nThreads:\t3\n"), None);
}
#[test]
fn pump_without_a_started_service_is_inert() {
struct NoUi;
impl DevtoolsUi for NoUi {
fn inspect(&self) -> Vec<InspectNode> {
panic!("pump must not touch the UI with nothing queued");
}
fn dispatch(&mut self, _event: InputEvent) {
panic!("pump must not touch the UI with nothing queued");
}
}
pump(&mut NoUi);
}
}