Skip to main content

ferroui_inspect/
lib.rs

1//! Transport-independent inspection of live FerroUI objects, shared by the MCP server
2//! (`ferroui-mcp`, the JSON tools of [`execute`]) and the Chrome DevTools Protocol endpoint
3//! (`ferroui-devtools`, the per-window [`target::Target`]). Both use one [`Inspector`] per UI
4//! thread ([`Inspector::shared`]): one ID space, one set of roots and the same primitives for
5//! properties, styles, selectors, layout boxes, screenshots and highlights. All methods run on the
6//! UI thread. IDs are weak, stable for an object's lifetime, never reused, and start at 2 (0 and 1
7//! are left to protocols, such as CDP's document node).
8mod frames;
9mod input;
10mod logs;
11mod schema;
12pub mod target;
13use base64::Engine;
14use ferroui_base::controls::{IResourceHost, ResourceKey};
15use ferroui_base::data::core::{ValueType, ValueTypes};
16use ferroui_base::data::{BindingOperations, BindingPriority};
17use ferroui_base::diagnostics::FerroObjectDiagnosticExtensions;
18use ferroui_base::layout::Layoutable;
19use ferroui_base::media::imaging::{PngBitmapEncoderOptions, RenderTargetBitmap};
20use ferroui_base::media::{Colors, SolidColorBrush};
21use ferroui_base::reactive::{Disposable, IDisposable};
22use ferroui_base::styling::{ControlTheme, Selector, Style};
23use ferroui_base::threading::Dispatcher;
24use ferroui_base::{
25    BoxedValue, FerroObject, FerroProperty, FerroPropertyRegistry, PixelSize, Rect, Ref,
26    StyledElement, Thickness, TypeInfo, Vector, Visual, WeakRef,
27};
28use ferroui_controls::automation::peers::{AutomationPeer, ControlAutomationPeer};
29use ferroui_controls::primitives::{AdornerLayer, TemplatedControl};
30use ferroui_controls::{Application, Border, Control, TopLevel};
31use ferroui_markup::markup::parsers::{SelectorGrammar, SelectorParser, SelectorSyntax};
32pub use logs::{LogCapture, LogEntry, LogGuard};
33pub use schema::{tools, validate};
34use serde_json::{json, Value};
35use std::cell::RefCell;
36use std::collections::{HashMap, HashSet};
37use std::rc::Rc;
38
39/// Mutating operations are disabled unless explicitly enabled by the application.
40#[derive(Clone, Copy, Debug, Default)]
41pub struct Permissions {
42    pub allow_property_writes: bool,
43    pub allow_input: bool,
44}
45
46pub type InspectResult = Result<Value, String>;
47pub type NodeId = u64;
48
49/// The tree that children are read from.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
51pub enum TreeKind {
52    #[default]
53    Visual,
54    Logical,
55    Automation,
56}
57impl TreeKind {
58    fn parse(name: &str) -> Self {
59        match name {
60            "logical" => Self::Logical,
61            "automation" => Self::Automation,
62            _ => Self::Visual,
63        }
64    }
65}
66
67/// A registered or attached property of an object, its effective value and where it comes from.
68#[derive(Clone)]
69pub struct PropertyInfo {
70    pub property: &'static FerroProperty,
71    /// `Owner.Name` for an attached property.
72    pub name: String,
73    pub value: BoxedValue,
74    /// `None` for a direct property, which has no priority.
75    pub priority: Option<BindingPriority>,
76    pub read_only: bool,
77    pub has_binding: bool,
78    pub overridden_current_value: bool,
79}
80impl PropertyInfo {
81    pub fn priority_name(&self) -> String {
82        self.priority
83            .map_or_else(|| "Direct".into(), |priority| format!("{priority:?}"))
84    }
85    pub fn is_local(&self) -> bool {
86        self.priority == Some(BindingPriority::LocalValue)
87    }
88}
89
90/// A frame of the value store that applies to an object: a style, a control theme, a template or
91/// the local values.
92#[derive(Clone)]
93pub struct StyleFrame {
94    pub kind: String,
95    /// The selector of a style, or `Theme <type>` of a control theme.
96    pub selector: Option<String>,
97    pub active: bool,
98    pub priority: String,
99    pub setters: Vec<(String, Option<BoxedValue>)>,
100}
101
102/// The layout boxes of a visual, in the coordinates of another one.
103#[derive(Clone, Debug)]
104pub struct BoxModel {
105    pub content: Rect,
106    pub padding: Rect,
107    pub border: Rect,
108    pub margin: Rect,
109}
110
111pub struct Png {
112    pub data: Vec<u8>,
113    pub width: f64,
114    pub height: f64,
115}
116
117/// A highlight adorner of [`Inspector::highlight`]; dropping it removes the adorner.
118pub struct Highlight(Rc<dyn IDisposable>);
119impl Drop for Highlight {
120    fn drop(&mut self) {
121        self.0.dispose();
122    }
123}
124
125ferroui_base::ferro_thread_local! {
126    static SHARED: RefCell<Option<Rc<RefCell<Inspector>>>> = const { RefCell::new(None) };
127}
128
129pub struct Inspector {
130    next_id: NodeId,
131    nodes: HashMap<NodeId, WeakRef<FerroObject>>,
132    addresses: HashMap<usize, NodeId>,
133    roots: Vec<WeakRef<TopLevel>>,
134    frames: HashMap<NodeId, frames::FrameStats>,
135    overlays: Vec<WeakRef<Visual>>,
136}
137
138impl Default for Inspector {
139    fn default() -> Self {
140        Self::new()
141    }
142}
143
144impl Inspector {
145    pub fn new() -> Self {
146        Dispatcher::ui_thread().verify_access();
147        Self {
148            next_id: 2,
149            nodes: HashMap::new(),
150            addresses: HashMap::new(),
151            roots: Vec::new(),
152            frames: HashMap::new(),
153            overlays: Vec::new(),
154        }
155    }
156
157    /// The inspector of this UI thread, shared by every server that runs on it.
158    pub fn shared() -> Rc<RefCell<Inspector>> {
159        SHARED.with(|shared| {
160            shared
161                .borrow_mut()
162                .get_or_insert_with(|| Rc::new(RefCell::new(Inspector::new())))
163                .clone()
164        })
165    }
166
167    /// Add a top level for a custom lifetime or tests. Desktop windows are discovered automatically.
168    pub fn register_root(&mut self, root: &TopLevel) {
169        if !self.roots.iter().any(|weak| weak.points_to(&root.to_ref())) {
170            self.roots.push(root.to_ref().downgrade());
171        }
172    }
173
174    pub fn node_id(&mut self, object: &FerroObject) -> NodeId {
175        let address = object as *const FerroObject as usize;
176        if let Some(id) = self.addresses.get(&address) {
177            if self
178                .nodes
179                .get(id)
180                .and_then(WeakRef::upgrade)
181                .is_some_and(|old| old == object.to_ref())
182            {
183                return *id;
184            }
185        }
186        let id = self.next_id;
187        self.next_id += 1;
188        self.nodes.insert(id, object.to_ref().downgrade());
189        self.addresses.insert(address, id);
190        id
191    }
192
193    pub fn resolve(&self, id: NodeId) -> Result<Ref<FerroObject>, String> {
194        self.nodes
195            .get(&id)
196            .and_then(WeakRef::upgrade)
197            .ok_or_else(|| format!("Unknown or expired node_id: {id}"))
198    }
199
200    fn prune(&mut self) {
201        self.nodes.retain(|_, weak| weak.upgrade().is_some());
202        self.addresses.retain(|_, id| self.nodes.contains_key(id));
203        self.overlays.retain(|weak| weak.upgrade().is_some());
204    }
205
206    /// The open top levels: the registered roots and the windows of the desktop lifetime.
207    pub fn windows(&mut self) -> Vec<Ref<TopLevel>> {
208        self.prune();
209        self.roots.retain(|weak| weak.upgrade().is_some());
210        let mut roots: Vec<_> = self
211            .roots
212            .iter()
213            .filter_map(WeakRef::upgrade)
214            .filter(|r| r.platform_impl().is_some())
215            .collect();
216        if let Some(lifetime) = Application::current().and_then(|a| a.application_lifetime()) {
217            if let Some(desktop) = lifetime.as_classic_desktop_style_application_lifetime() {
218                for window in desktop.windows() {
219                    let root = window.upcast::<TopLevel>();
220                    if !roots.contains(&root) {
221                        roots.push(root);
222                    }
223                }
224            }
225        }
226        for root in &roots {
227            let id = self.node_id(root);
228            self.frames
229                .entry(id)
230                .or_insert_with(|| frames::FrameStats::new(root));
231        }
232        let live: HashSet<_> = roots.iter().map(|r| self.node_id(r)).collect();
233        self.frames.retain(|id, _| live.contains(id));
234        roots
235    }
236
237    fn roots(&mut self, args: &Value) -> Result<Vec<Ref<FerroObject>>, String> {
238        if let Some(id) = args["node_id"].as_u64() {
239            Ok(vec![self.resolve(id)?])
240        } else {
241            Ok(self.windows().into_iter().map(Ref::upcast).collect())
242        }
243    }
244
245    fn object(&self, args: &Value) -> Result<Ref<FerroObject>, String> {
246        self.resolve(args["node_id"].as_u64().ok_or("node_id required")?)
247    }
248
249    /// The control that owns an automation peer, or the object itself.
250    pub fn visual_object(object: &Ref<FerroObject>) -> Ref<FerroObject> {
251        object
252            .cast::<ControlAutomationPeer>()
253            .and_then(|p| p.try_owner())
254            .map(Ref::upcast)
255            .unwrap_or_else(|| object.clone())
256    }
257
258    /// Whether the object is a highlight adorner of this inspector, which trees leave out.
259    pub fn is_overlay(&self, object: &FerroObject) -> bool {
260        self.overlays
261            .iter()
262            .filter_map(WeakRef::upgrade)
263            .any(|overlay| overlay.upcast::<FerroObject>() == object.to_ref())
264    }
265
266    /// The children of the object in the tree, without highlight adorners.
267    pub fn children(&self, object: &Ref<FerroObject>, tree: TreeKind) -> Vec<Ref<FerroObject>> {
268        let children: Vec<Ref<FerroObject>> = match tree {
269            TreeKind::Automation => Self::peer(object)
270                .map(|p| p.get_children().iter().cloned().map(Ref::upcast).collect())
271                .unwrap_or_default(),
272            TreeKind::Logical => object
273                .cast::<StyledElement>()
274                .map(|o| {
275                    o.logical_children()
276                        .to_vec()
277                        .into_iter()
278                        .map(Ref::upcast)
279                        .collect()
280                })
281                .unwrap_or_default(),
282            TreeKind::Visual => object
283                .cast::<Visual>()
284                .map(|o| {
285                    o.visual_children()
286                        .to_vec()
287                        .into_iter()
288                        .map(Ref::upcast)
289                        .collect()
290                })
291                .unwrap_or_default(),
292        };
293        if self.overlays.is_empty() {
294            return children;
295        }
296        children
297            .into_iter()
298            .filter(|child| !self.is_overlay(child))
299            .collect()
300    }
301
302    pub fn peer(object: &Ref<FerroObject>) -> Result<Ref<AutomationPeer>, String> {
303        object
304            .cast::<AutomationPeer>()
305            .or_else(|| {
306                object
307                    .cast::<Control>()
308                    .map(|c| ControlAutomationPeer::create_peer_for_element(&c))
309            })
310            .ok_or("Node has no automation peer".into())
311    }
312
313    fn registered(object: &FerroObject) -> Vec<&'static FerroProperty> {
314        let registry = FerroPropertyRegistry::instance();
315        let mut result = registry.get_registered_for(object).to_vec();
316        result.extend(
317            registry
318                .get_registered_attached(object.get_type())
319                .iter()
320                .copied(),
321        );
322        result.sort_by_key(|p| property_name(p));
323        result.dedup_by_key(|p| *p as *const FerroProperty);
324        result
325    }
326
327    /// A registered or attached (`Owner.Name`) property of the object.
328    pub fn property(object: &FerroObject, name: &str) -> Result<&'static FerroProperty, String> {
329        Self::registered(object)
330            .into_iter()
331            .find(|p| property_name(p) == name)
332            .ok_or_else(|| format!("Unknown property: {name}"))
333    }
334
335    /// The registered and attached properties of the object, sorted by name.
336    pub fn properties(object: &FerroObject) -> Vec<PropertyInfo> {
337        Self::registered(object)
338            .into_iter()
339            .map(|p| {
340                let diagnostic = object.get_diagnostic(p);
341                PropertyInfo {
342                    property: p,
343                    name: property_name(p),
344                    value: diagnostic.value().clone(),
345                    priority: (!p.is_direct()).then(|| diagnostic.priority()),
346                    read_only: p.is_read_only(),
347                    has_binding: BindingOperations::get_binding_expression_base(object, p)
348                        .is_some(),
349                    overridden_current_value: diagnostic.is_overridden_current_value(),
350                }
351            })
352            .collect()
353    }
354
355    /// Sets a local value: `None` sets null, text is converted with the framework's converters.
356    /// Read-only properties, values that do not convert and values that fail validation are
357    /// refused before the object is changed.
358    pub fn set_property(
359        object: &FerroObject,
360        name: &str,
361        value: Option<BoxedValue>,
362    ) -> Result<&'static FerroProperty, String> {
363        let property = Self::property(object, name)?;
364        if property.is_read_only() {
365            return Err(format!("{name} is read-only"));
366        }
367        let converted = ValueTypes::try_convert(
368            value.as_ref(),
369            ValueType::new(property.property_type(), property.property_type_name()),
370        )
371        .flatten()
372        .ok_or_else(|| format!("Cannot convert the value to the type of {name}"))?;
373        if !property.is_valid_value(converted.as_any()) {
374            return Err(format!("Validation of {name} rejected the value"));
375        }
376        object.set_value_untyped(property, converted.as_any(), BindingPriority::LocalValue);
377        Ok(property)
378    }
379
380    pub fn clear_property(
381        object: &FerroObject,
382        name: &str,
383    ) -> Result<&'static FerroProperty, String> {
384        let property = Self::property(object, name)?;
385        if property.is_read_only() {
386            return Err(format!("{name} is read-only"));
387        }
388        object.clear_value_untyped(property);
389        Ok(property)
390    }
391
392    /// The frames of the value store that apply to the object, active or not.
393    pub fn styles(object: &FerroObject) -> Vec<StyleFrame> {
394        object
395            .get_value_store_diagnostic()
396            .applied_frames()
397            .iter()
398            .map(|frame| {
399                let source = frame.source().and_then(|s| ValueTypes::as_object(&*s));
400                let selector = source
401                    .as_ref()
402                    .and_then(|s| s.cast::<Style>())
403                    .and_then(|s| s.selector())
404                    .map(|s| s.to_string_with_owner(None))
405                    .or_else(|| {
406                        source.and_then(|s| s.cast::<ControlTheme>()).map(|t| {
407                            format!(
408                                "Theme {}",
409                                t.target_type().map(TypeInfo::name).unwrap_or_default()
410                            )
411                        })
412                    });
413                StyleFrame {
414                    kind: format!("{:?}", frame.type_()),
415                    selector,
416                    active: frame.is_active(),
417                    priority: format!("{:?}", frame.priority()),
418                    setters: frame
419                        .values()
420                        .into_iter()
421                        .map(|v| (property_name(v.property), v.value))
422                        .collect(),
423                }
424            })
425            .collect()
426    }
427
428    /// Parses a selector of the framework's syntax. Type names resolve among the known types and
429    /// the types of the live nodes. Forms that the selector constructors or the matcher do not
430    /// accept outside a style (nesting, a dangling combinator) are refused here.
431    pub fn parse_selector(&self, text: &str) -> Result<Option<Selector>, String> {
432        if text.trim().is_empty() || text.len() > 4096 {
433            return Err("Selector must contain 1..4096 bytes".into());
434        }
435        let mut nesting = 0usize;
436        for character in text.chars() {
437            if character == '(' {
438                nesting += 1;
439            }
440            if nesting > 32 {
441                return Err("Selector nesting is too deep".into());
442            }
443            if character == ')' {
444                nesting = nesting.saturating_sub(1);
445            }
446        }
447        validate_selector(&SelectorGrammar::parse(text).map_err(|e| e.to_string())?)?;
448        let mut live: HashMap<String, &'static TypeInfo> = HashMap::new();
449        for object in self.nodes.values().filter_map(WeakRef::upgrade) {
450            let mut type_ = Some(object.get_type());
451            while let Some(t) = type_ {
452                live.insert(t.name().into(), t);
453                type_ = t.base_type();
454            }
455        }
456        SelectorParser::new(move |namespace, name| {
457            if namespace.is_empty() {
458                TypeInfo::find_by_name(name).or_else(|| live.get(name).copied())
459            } else {
460                TypeInfo::find(namespace, name)
461            }
462        })
463        .parse(text)
464        .map_err(|e| e.to_string())
465    }
466
467    /// The content, padding, border and margin boxes of the visual, in the coordinates of
468    /// `relative_to`.
469    pub fn box_model(visual: &Ref<Visual>, relative_to: &Ref<Visual>) -> Result<BoxModel, String> {
470        let transform = visual
471            .transform_to_visual(relative_to)
472            .ok_or("Node has no transform to the root")?;
473        let border = Rect::from_size(visual.bounds().size());
474        let margin = visual.get_value(Layoutable::margin_property());
475        let padding = visual.get_value(TemplatedControl::padding_property());
476        let border_thickness = visual
477            .cast::<Border>()
478            .map(|b| b.border_thickness())
479            .or_else(|| {
480                visual
481                    .cast::<TemplatedControl>()
482                    .map(|c| c.border_thickness())
483            })
484            .unwrap_or_default();
485        let padding_rect = border.deflate_thickness(border_thickness);
486        Ok(BoxModel {
487            content: padding_rect
488                .deflate_thickness(padding)
489                .transform_to_aabb(transform),
490            padding: padding_rect.transform_to_aabb(transform),
491            border: border.transform_to_aabb(transform),
492            margin: border
493                .inflate_thickness(margin)
494                .transform_to_aabb(transform),
495        })
496    }
497
498    /// A PNG of the visual at the scale (0.1 to 4), at most 16 megapixels.
499    pub fn screenshot(visual: &Ref<Visual>, scale: f64) -> Result<Png, String> {
500        let size = visual.bounds().size();
501        let width = (size.width * scale).ceil();
502        let height = (size.height * scale).ceil();
503        if !width.is_finite()
504            || !height.is_finite()
505            || width < 1.0
506            || height < 1.0
507            || width * height > 16_000_000.0
508        {
509            return Err("Screenshot size is empty or exceeds 16 megapixels".into());
510        }
511        let bitmap = RenderTargetBitmap::with_dpi(
512            PixelSize::new(width as i32, height as i32),
513            Vector::new(96.0 * scale, 96.0 * scale),
514        );
515        bitmap.render(visual);
516        let mut data = Vec::new();
517        let result = bitmap.save(&mut data, &PngBitmapEncoderOptions::DEFAULT.into());
518        bitmap.dispose();
519        result.map_err(|e| e.to_string())?;
520        Ok(Png {
521            data,
522            width,
523            height,
524        })
525    }
526
527    /// Outlines the visual on its adorner layer until the returned guard is dropped. The adorner
528    /// is left out of the trees of every client of this inspector.
529    pub fn highlight(&mut self, visual: &Ref<Visual>) -> Result<Highlight, String> {
530        let layer = AdornerLayer::get_adorner_layer(visual).ok_or("Node has no adorner layer")?;
531        let border = Border::new();
532        border.set_is_hit_test_visible(false);
533        border.set_border_thickness(Thickness::uniform(2.0));
534        border.set_border_brush(Some(
535            SolidColorBrush::with_color(Colors::DODGER_BLUE).into(),
536        ));
537        AdornerLayer::set_adorned_element(&border, visual.clone());
538        self.overlays
539            .push(border.clone().upcast::<Visual>().downgrade());
540        layer.children().add(border.clone());
541        Ok(Highlight(Disposable::create(move || {
542            layer.children().remove(&border);
543        })))
544    }
545
546    fn describe(&mut self, object: &Ref<FerroObject>, properties: Option<&Value>) -> Value {
547        let id = self.node_id(object);
548        let visual_object = Self::visual_object(object);
549        let mut result = json!({"node_id":id,"type":visual_object.get_type().name()});
550        if let Some(styled) = visual_object.cast::<StyledElement>() {
551            result["name"] = json!(styled.name());
552        }
553        if let Some(visual) = visual_object.cast::<Visual>() {
554            result["bounds"] = rect(visual.bounds());
555            result["visible"] = json!(visual.is_visible());
556            if let Some(root) = visual.visual_root() {
557                if let Some(transform) = visual.transform_to_visual(&root) {
558                    result["bounds_in_root"] =
559                        rect(Rect::from_size(visual.bounds().size()).transform_to_aabb(transform));
560                    result["transform_to_root"] = json!(format!("{transform:?}"));
561                }
562            }
563        }
564        if let Some(layout) = visual_object.cast::<Layoutable>() {
565            result["desired_size"] =
566                json!({"width":layout.desired_size().width,"height":layout.desired_size().height});
567        }
568        if let Ok(peer) = Self::peer(object) {
569            result["automation_id"] = json!(peer.get_automation_id());
570            result["text"] = json!(peer.get_name());
571            result["role"] = json!(format!("{:?}", peer.get_automation_control_type()));
572            result["enabled"] = json!(peer.is_enabled());
573        }
574        if let Some(properties) = properties {
575            result["properties"] = properties_json(&visual_object, Some(properties));
576        }
577        result
578    }
579
580    fn tree(
581        &mut self,
582        object: Ref<FerroObject>,
583        tree: TreeKind,
584        depth: u64,
585        properties: Option<&Value>,
586        visited: &mut HashSet<NodeId>,
587    ) -> Value {
588        let object = if tree == TreeKind::Automation {
589            Self::peer(&object).map(Ref::upcast).unwrap_or(object)
590        } else {
591            object
592        };
593        let id = self.node_id(&object);
594        if visited.len() >= 10000 || !visited.insert(id) {
595            return json!({"node_id":id,"truncated":true});
596        }
597        let mut result = self.describe(&object, properties);
598        let children = self.children(&object, tree);
599        result["child_count"] = json!(children.len());
600        if depth > 0 {
601            let mut snapshots = Vec::new();
602            for child in children {
603                if visited.len() >= 10000 {
604                    result["truncated"] = json!(true);
605                    break;
606                }
607                snapshots.push(self.tree(child, tree, depth - 1, properties, visited));
608            }
609            result["children"] = Value::Array(snapshots);
610        } else if !children.is_empty() {
611            result["truncated"] = json!(true);
612        }
613        result
614    }
615
616    /// The matches of the filters of `find_elements`, and whether the search stopped at the node
617    /// limit before it covered the tree.
618    fn find(&mut self, args: &Value) -> Result<(Vec<Ref<FerroObject>>, bool), String> {
619        let selector = args["selector"]
620            .as_str()
621            .map(|s| self.parse_selector(s))
622            .transpose()?
623            .flatten();
624        let mut stack = self.roots(args)?;
625        stack.reverse();
626        let mut visited = HashSet::new();
627        let mut found = Vec::new();
628        let limit = args["limit"].as_u64().unwrap_or(1000) as usize;
629        while let Some(object) = stack.pop() {
630            if !visited.insert(self.node_id(&object)) {
631                continue;
632            }
633            if visited.len() > 10000 {
634                return Ok((found, true));
635            }
636            let mut children = self.children(&object, TreeKind::Visual);
637            children.extend(self.children(&object, TreeKind::Logical));
638            stack.extend(children.into_iter().rev());
639            let target = Self::visual_object(&object);
640            if args["type"]
641                .as_str()
642                .is_some_and(|s| target.get_type().name() != s)
643            {
644                continue;
645            }
646            if args["name"].as_str().is_some_and(|s| {
647                target
648                    .cast::<StyledElement>()
649                    .and_then(|o| o.name())
650                    .as_deref()
651                    != Some(s)
652            }) {
653                continue;
654            }
655            if let Some(selector) = &selector {
656                if !target
657                    .cast::<StyledElement>()
658                    .is_some_and(|o| selector.match_(&o, None, false).is_match())
659                {
660                    continue;
661                }
662            }
663            if let Some(s) = args["automation_id"].as_str() {
664                if Self::peer(&target)
665                    .ok()
666                    .and_then(|p| p.get_automation_id())
667                    .as_deref()
668                    != Some(s)
669                {
670                    continue;
671                }
672            }
673            if let Some(s) = args["text"].as_str() {
674                if !Self::peer(&target).is_ok_and(|p| p.get_name().contains(s)) {
675                    continue;
676                }
677            }
678            found.push(target);
679            if found.len() >= limit {
680                break;
681            }
682        }
683        Ok((found, false))
684    }
685
686    /// Execute a single UI-thread operation of the MCP tools. `wait_for` is a nonblocking
687    /// condition probe; the transport polls it until `matched` or its deadline, leaving the UI
688    /// free to render. Input tools are run by [`execute`] instead, which does not hold the shared
689    /// inspector while the application handles the input.
690    pub fn execute(&mut self, name: &str, args: &Value, permissions: Permissions) -> InspectResult {
691        Dispatcher::ui_thread().verify_access();
692        validate(name, args)?;
693        catch(|| {
694            if is_input(name) {
695                let object = self.prepare_input(args, permissions)?;
696                input::execute(name, &object, args, permissions)?;
697                return Ok(json!({"ok":true}));
698            }
699            self.execute_inner(name, args, permissions)
700        })
701    }
702
703    fn prepare_input(
704        &mut self,
705        args: &Value,
706        permissions: Permissions,
707    ) -> Result<Ref<FerroObject>, String> {
708        if !permissions.allow_input {
709            return Err("Input is disabled".into());
710        }
711        self.prune();
712        self.object(args)
713    }
714
715    fn execute_inner(
716        &mut self,
717        name: &str,
718        args: &Value,
719        permissions: Permissions,
720    ) -> InspectResult {
721        self.prune();
722        match name {
723            "list_windows" => {
724                let roots = self.windows();
725                Ok(Value::Array(
726                    roots
727                        .iter()
728                        .map(|r| self.describe(&r.clone().upcast(), None))
729                        .collect(),
730                ))
731            }
732            "get_tree" => {
733                let roots = self.roots(args)?;
734                let mut visited = HashSet::new();
735                let tree = TreeKind::parse(args["tree"].as_str().unwrap_or("visual"));
736                Ok(Value::Array(
737                    roots
738                        .into_iter()
739                        .map(|root| {
740                            self.tree(
741                                root,
742                                tree,
743                                args["depth"].as_u64().unwrap_or(8),
744                                args.get("properties"),
745                                &mut visited,
746                            )
747                        })
748                        .collect(),
749                ))
750            }
751            "find_elements" => {
752                // Past the node limit the matches found so far are the result; none is an error.
753                let (found, truncated) = self.find(args)?;
754                if truncated && found.is_empty() {
755                    return Err("Search exceeded 10000 nodes; scope it with node_id".into());
756                }
757                Ok(Value::Array(
758                    found.iter().map(|o| self.describe(o, None)).collect(),
759                ))
760            }
761            "get_properties" => Ok(properties_json(
762                &Self::visual_object(&self.object(args)?),
763                args.get("properties"),
764            )),
765            "set_property" => {
766                if !permissions.allow_property_writes {
767                    return Err("Property writes are disabled".into());
768                }
769                let object = Self::visual_object(&self.object(args)?);
770                let name = args["property"].as_str().unwrap();
771                let property = if args["clear"].as_bool().unwrap_or(false) {
772                    Self::clear_property(&object, name)?
773                } else {
774                    let value = args
775                        .get("value")
776                        .ok_or("value required unless clear is true")?;
777                    let source: Option<BoxedValue> = match value {
778                        Value::Null => None,
779                        Value::Bool(v) => Some(Rc::new(*v)),
780                        Value::Number(v) => Some(Rc::new(v.as_f64().ok_or("Invalid number")?)),
781                        Value::String(v) => Some(Rc::new(v.clone())),
782                        _ => {
783                            return Err(
784                                "Use a boolean, number, string (framework syntax), or null".into()
785                            )
786                        }
787                    };
788                    Self::set_property(&object, name, source)?
789                };
790                Ok(properties_json(
791                    &object,
792                    Some(&json!([property_name(property)])),
793                ))
794            }
795            "get_styles" => {
796                let object = Self::visual_object(&self.object(args)?);
797                Ok(Value::Array(
798                    Self::styles(&object)
799                        .into_iter()
800                        .map(|frame| {
801                            json!({"kind":frame.kind,"selector":frame.selector,"active":frame.active,"priority":frame.priority,"setters":frame.setters.into_iter().map(|(property,value)|json!({"property":property,"value":boxed(value)})).collect::<Vec<_>>()})
802                        })
803                        .collect(),
804                ))
805            }
806            "get_resources" => {
807                let object = Self::visual_object(&self.object(args)?)
808                    .cast::<StyledElement>()
809                    .ok_or("Node is not a styled element")?;
810                let host: &dyn IResourceHost = &*object;
811                let keys: Vec<ResourceKey> = if let Some(keys) = args["keys"].as_array() {
812                    keys.iter()
813                        .map(|k| ResourceKey::from(k.as_str().unwrap()))
814                        .collect()
815                } else {
816                    // Listing the local keys leaves an element without resources as it is.
817                    object
818                        .resources_if_created()
819                        .map(|resources| resources.keys())
820                        .unwrap_or_default()
821                };
822                Ok(Value::Array(keys.into_iter().map(|key| { let value = host.try_find_resource(&key,object.actual_theme_variant().as_ref()); json!({"key":key.as_str().map(str::to_owned).unwrap_or_else(||format!("{key:?}")),"found":value.is_some(),"value":boxed(value.flatten())}) }).collect()))
823            }
824            "screenshot" => {
825                let object = self
826                    .roots(args)?
827                    .into_iter()
828                    .next()
829                    .ok_or("No window available")?;
830                let visual = Self::visual_object(&object)
831                    .cast::<Visual>()
832                    .ok_or("Node is not visual")?;
833                let png = Self::screenshot(&visual, args["scale"].as_f64().unwrap_or(1.0))?;
834                Ok(
835                    json!({"mimeType":"image/png","data":base64::engine::general_purpose::STANDARD.encode(png.data),"width":png.width,"height":png.height}),
836                )
837            }
838            "wait_for" => {
839                let (found, truncated) = self.find(args)?;
840                if truncated && found.is_empty() {
841                    return Err("Search exceeded 10000 nodes; scope it with node_id".into());
842                }
843                let condition = args["condition"].as_str().unwrap_or("exists");
844                if condition == "property_equals"
845                    && (args["property"].as_str().is_none() || args.get("value").is_none())
846                {
847                    return Err("property and value required for property_equals".into());
848                }
849                let matched = match condition {
850                    "absent" => found.is_empty(),
851                    "visible" => found.iter().any(|o| {
852                        o.cast::<Visual>().is_some_and(|v| v.is_visible())
853                            && Self::peer(o).is_ok_and(|p| !p.is_offscreen())
854                    }),
855                    "enabled" => found
856                        .iter()
857                        .any(|o| Self::peer(o).is_ok_and(|p| p.is_enabled())),
858                    "property_equals" => found.iter().any(|o| {
859                        Self::property(o, args["property"].as_str().unwrap())
860                            .is_ok_and(|p| boxed(Some(o.get_value_untyped(p))) == args["value"])
861                    }),
862                    _ => !found.is_empty(),
863                };
864                Ok(
865                    json!({"matched":matched,"node_ids":found.iter().map(|o|self.node_id(o)).collect::<Vec<_>>()}),
866                )
867            }
868            "get_logs" => Ok(LogCapture::snapshot(args["after"].as_u64().unwrap_or(0))),
869            "get_frame_stats" => {
870                let windows = self.windows();
871                Ok(Value::Array(
872                    windows
873                        .iter()
874                        .map(|w| {
875                            let t = w.renderer_diagnostics().last_layout_pass_timing();
876                            let id = self.node_id(w);
877                            let mut stats = self.frames[&id].snapshot();
878                            stats["node_id"] = json!(id);
879                            stats["layout_pass_count"] = json!(t.pass_counter);
880                            stats["layout_ms"] = json!(t.elapsed.as_secs_f64() * 1000.0);
881                            stats
882                        })
883                        .collect(),
884                ))
885            }
886            _ => Err(format!("Unknown tool: {name}")),
887        }
888    }
889}
890
891/// Executes an MCP tool on the shared inspector of this thread ([`Inspector::shared`]). The
892/// inspector is held only while it reads or changes the objects: an input tool resolves its node,
893/// releases the inspector and then delivers the input, so that the handlers of the application,
894/// which may run a nested dispatcher frame, can reach the inspector again (through another
895/// server, for example).
896pub fn execute(name: &str, args: &Value, permissions: Permissions) -> InspectResult {
897    Dispatcher::ui_thread().verify_access();
898    validate(name, args)?;
899    let shared = Inspector::shared();
900    if is_input(name) {
901        let object = {
902            let mut inspector = shared
903                .try_borrow_mut()
904                .map_err(|_| "The inspector is busy".to_owned())?;
905            inspector.prepare_input(args, permissions)?
906        };
907        return catch(|| {
908            input::execute(name, &object, args, permissions)?;
909            Ok(json!({"ok":true}))
910        });
911    }
912    let mut inspector = shared
913        .try_borrow_mut()
914        .map_err(|_| "The inspector is busy".to_owned())?;
915    catch(|| inspector.execute_inner(name, args, permissions))
916}
917
918fn is_input(name: &str) -> bool {
919    matches!(
920        name,
921        "click" | "type_text" | "press_key" | "scroll" | "invoke_pattern"
922    )
923}
924
925fn catch(f: impl FnOnce() -> InspectResult) -> InspectResult {
926    std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|error| {
927        let message = error
928            .downcast_ref::<String>()
929            .map(String::as_str)
930            .or_else(|| error.downcast_ref::<&str>().copied())
931            .unwrap_or("UI operation panicked");
932        Err(message.to_owned())
933    })
934}
935
936fn properties_json(object: &FerroObject, filter: Option<&Value>) -> Value {
937    let names = filter.and_then(Value::as_array);
938    Value::Array(
939        Inspector::properties(object)
940            .into_iter()
941            .filter(|p| names.is_none_or(|names| names.iter().any(|n| n == &p.name)))
942            .map(|p| {
943                json!({"name":p.name,"type":p.property.property_type_name(),"value":boxed(Some(p.value.clone())),"read_only":p.read_only,"priority":p.priority_name(),"binding":p.has_binding,"overridden_current_value":p.overridden_current_value})
944            })
945            .collect(),
946    )
947}
948
949/// The name of the property as the tools and selectors use it: `Owner.Name` when attached.
950pub fn property_name(p: &FerroProperty) -> String {
951    if p.is_attached() {
952        format!("{}.{}", p.owner_type().name(), p.name())
953    } else {
954        p.name().to_owned()
955    }
956}
957
958/// The text of a value as the framework displays it.
959pub fn display_value(value: Option<&BoxedValue>) -> String {
960    value
961        .cloned()
962        .and_then(ValueTypes::normalize)
963        .as_ref()
964        .map(|v| ValueTypes::to_display_string(Some(v)))
965        .unwrap_or_default()
966}
967
968fn rect(r: Rect) -> Value {
969    json!({"x":r.x,"y":r.y,"width":r.width,"height":r.height})
970}
971fn boxed(value: Option<BoxedValue>) -> Value {
972    let Some(value) = value.and_then(ValueTypes::normalize) else {
973        return Value::Null;
974    };
975    macro_rules! scalar {($($t:ty),*) => {$(if let Some(v) = value.downcast_ref::<$t>() {return json!(v);})*};}
976    scalar!(bool, String, i32, i64, u32, u64, usize);
977    if let Some(v) = value.downcast_ref::<f64>() {
978        return if v.is_finite() {
979            json!(v)
980        } else {
981            json!(v.to_string())
982        };
983    }
984    if let Some(v) = value.downcast_ref::<f32>() {
985        return if v.is_finite() {
986            json!(v)
987        } else {
988            json!(v.to_string())
989        };
990    }
991    ValueTypes::try_to_string(&*value)
992        .map(Value::String)
993        .unwrap_or_else(|| json!({"opaque":true}))
994}
995
996// Style-only nesting and incomplete combinators panic in the framework's selector constructors
997// or matcher. Validate the public grammar before constructing a selector from a remote request.
998fn validate_selector(syntax: &[SelectorSyntax]) -> Result<(), String> {
999    let mut term = false;
1000    for item in syntax {
1001        match item {
1002            SelectorSyntax::Nesting => {
1003                return Err("Style nesting selectors have no parent style in a query".into())
1004            }
1005            SelectorSyntax::Child
1006            | SelectorSyntax::Descendant
1007            | SelectorSyntax::Template
1008            | SelectorSyntax::Comma => {
1009                if !term {
1010                    return Err("Selector combinator requires a preceding selector".into());
1011                }
1012                term = false;
1013            }
1014            SelectorSyntax::Not { argument } => {
1015                validate_selector(argument)?;
1016                term = true;
1017            }
1018            _ => term = true,
1019        }
1020    }
1021    if !term {
1022        return Err("Selector requires a final selector term".into());
1023    }
1024    Ok(())
1025}
1026
1027#[cfg(test)]
1028mod tests;