Skip to main content

ferroui_devtools/
browser.rs

1use crate::protocol::{response, string, CdpSession, Error, Result};
2use ferroui_base::logging::LogEventLevel;
3use ferroui_base::reactive::IDisposable;
4use ferroui_base::{Ref, WeakRef};
5use ferroui_controls::{Application, TopLevel, Window};
6use ferroui_inspect::{LogCapture, LogEntry, LogGuard};
7use serde_json::{json, Value};
8use std::cell::Cell;
9use std::collections::{BTreeMap, HashSet};
10use std::rc::Rc;
11
12struct Target {
13    root: WeakRef<TopLevel>,
14    closed: Rc<Cell<bool>>,
15    subscription: Rc<dyn IDisposable>,
16}
17impl Drop for Target {
18    fn drop(&mut self) {
19        self.subscription.dispose();
20    }
21}
22struct Connection {
23    direct: Option<String>,
24    browser_sessions: HashSet<String>,
25    parents: BTreeMap<String, String>,
26    sessions: BTreeMap<String, (String, CdpSession)>,
27    discover: bool,
28    auto_attach: bool,
29    known: HashSet<String>,
30    events: Vec<Value>,
31}
32
33/// UI-thread target registry and flattened CDP session router, shared by the native server and
34/// browser bridge. Window and popup discovery follows the desktop application lifetime.
35pub struct Browser {
36    targets: BTreeMap<String, Target>,
37    connections: BTreeMap<u64, Connection>,
38    next_target: u64,
39    next_connection: u64,
40    next_session: u64,
41    /// The log capture shared with the other servers of the process.
42    _logs: LogGuard,
43}
44impl Default for Browser {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49impl Browser {
50    pub fn new() -> Self {
51        Self {
52            targets: BTreeMap::new(),
53            connections: BTreeMap::new(),
54            next_target: 1,
55            next_connection: 1,
56            next_session: 1,
57            _logs: LogCapture::install(),
58        }
59    }
60    /// Explicit registration also supports custom lifetimes. The registry retains only a weak root.
61    pub fn register(&mut self, root: &Ref<TopLevel>) -> String {
62        if let Some((id, _)) = self
63            .targets
64            .iter()
65            .find(|(_, t)| t.root.upgrade().as_ref() == Some(root) && !t.closed.get())
66        {
67            return id.clone();
68        }
69        let id = self.next_target.to_string();
70        self.next_target += 1;
71        let closed = Rc::new(Cell::new(false));
72        let flag = closed.clone();
73        let subscription = root.closed(move || flag.set(true));
74        self.targets.insert(
75            id.clone(),
76            Target {
77                root: root.downgrade(),
78                closed,
79                subscription,
80            },
81        );
82        id
83    }
84    pub fn refresh(&mut self) {
85        if let Some(lifetime) = Application::current().and_then(|a| a.application_lifetime()) {
86            if let Some(desktop) = lifetime.as_classic_desktop_style_application_lifetime() {
87                for window in desktop.windows() {
88                    self.register(&window.upcast());
89                }
90            }
91        }
92        let mut pending: Vec<_> = self
93            .targets
94            .values()
95            .filter_map(|t| t.root.upgrade())
96            .collect();
97        let mut seen = Vec::new();
98        while let Some(root) = pending.pop() {
99            if seen.contains(&root) {
100                continue;
101            }
102            seen.push(root.clone());
103            for popup in root.opened_popups() {
104                if let Some(top) = popup
105                    .host()
106                    .and_then(|h| h.as_popup_root())
107                    .map(|r| r.upcast::<TopLevel>())
108                {
109                    self.register(&top);
110                    pending.push(top);
111                }
112            }
113        }
114        self.targets
115            .retain(|_, t| !t.closed.get() && t.root.upgrade().is_some());
116    }
117    fn info(&self, id: &str) -> Result<Value> {
118        let root = self
119            .targets
120            .get(id)
121            .and_then(|t| t.root.upgrade())
122            .ok_or_else(|| Error::server("Unknown target"))?;
123        let title = root
124            .cast::<Window>()
125            .and_then(|w| w.title())
126            .unwrap_or_else(|| root.get_type().name().into());
127        Ok(
128            json!({"targetId":id,"type":"page","title":title,"url":format!("ferroui://{id}"),"attached":false,"canAccessOpener":false,"browserContextId":"default"}),
129        )
130    }
131    pub fn discovery(&mut self, port: u16) -> Value {
132        self.refresh();
133        self.targets.keys().filter_map(|id|self.info(id).ok()).map(|info| {
134            let id = info["targetId"].as_str().unwrap();
135            json!({"id":id,"type":"page","title":info["title"],"description":"FerroUI native visual tree","url":info["url"],"webSocketDebuggerUrl":format!("ws://127.0.0.1:{port}/devtools/page/{id}"),"devtoolsFrontendUrl":format!("devtools://devtools/bundled/devtools_app.html?ws=127.0.0.1:{port}/devtools/page/{id}")})
136        }).collect()
137    }
138    pub fn version(port: u16) -> Value {
139        json!({"Browser":concat!("FerroUI/",env!("CARGO_PKG_VERSION")),"Protocol-Version":"1.3","User-Agent":"FerroUI","V8-Version":"0","WebKit-Version":"0","webSocketDebuggerUrl":format!("ws://127.0.0.1:{port}/devtools/browser")})
140    }
141    pub fn connect(&mut self, target: Option<&str>) -> std::result::Result<u64, String> {
142        self.refresh();
143        let mut connection = Connection {
144            direct: target.map(str::to_owned),
145            browser_sessions: HashSet::new(),
146            parents: BTreeMap::new(),
147            sessions: BTreeMap::new(),
148            discover: false,
149            auto_attach: false,
150            known: HashSet::new(),
151            events: Vec::new(),
152        };
153        if let Some(target) = target {
154            let root = self
155                .targets
156                .get(target)
157                .and_then(|t| t.root.upgrade())
158                .ok_or("Unknown target")?;
159            connection.sessions.insert(
160                String::new(),
161                (target.into(), CdpSession::new(&root, target)),
162            );
163        }
164        let id = self.next_connection;
165        self.next_connection += 1;
166        self.connections.insert(id, connection);
167        Ok(id)
168    }
169    pub fn disconnect(&mut self, connection: u64) {
170        self.connections.remove(&connection);
171    }
172    pub fn handle(&mut self, connection: u64, message: &str) -> Vec<Value> {
173        self.refresh();
174        let Some(mut c) = self.connections.remove(&connection) else {
175            return vec![response(
176                Value::Null,
177                Err(Error::server("Connection closed")),
178            )];
179        };
180        let request: std::result::Result<Value, _> = serde_json::from_str(message);
181        let events_first = request.as_ref().is_ok_and(|r| {
182            r["method"] == "DOM.requestChildNodes"
183                || r["method"] == "DOM.pushNodesByBackendIdsToFrontend"
184                || r["method"] == "DOM.pushNodeByPathToFrontend"
185        });
186        let mut messages = match request {
187            Err(e) => vec![response(
188                Value::Null,
189                Err(Error {
190                    code: -32700,
191                    message: e.to_string(),
192                }),
193            )],
194            Ok(r) => {
195                let session = r["sessionId"].as_str().unwrap_or("");
196                let result = if !r["id"].is_u64() {
197                    Err(Error {
198                        code: -32600,
199                        message: "Request requires a nonnegative integer id".into(),
200                    })
201                } else if let Some(method) = r["method"].as_str() {
202                    let params = r.get("params").cloned().unwrap_or_else(|| json!({}));
203                    if !params.is_object() {
204                        Err(Error::params("params must be an object"))
205                    } else if (!session.is_empty() && !c.browser_sessions.contains(session))
206                        || (session.is_empty()
207                            && c.direct.is_some()
208                            && !method.starts_with("Target.")
209                            && !method.starts_with("Browser."))
210                    {
211                        match c.sessions.get_mut(session) {
212                            Some((target, s)) if self.targets.contains_key(target) => {
213                                let result = s.command(method, &params);
214                                for event in s.poll() {
215                                    c.events.push(with_session(event, session));
216                                }
217                                result
218                            }
219                            _ => Err(Error::server("Unknown or closed session")),
220                        }
221                    } else {
222                        self.browser_command(&mut c, method, &params, session)
223                    }
224                } else {
225                    Err(Error::params("method must be a string"))
226                };
227                vec![with_session(response(r["id"].clone(), result), session)]
228            }
229        };
230        if events_first {
231            // DOM clients resolve getChildNodes when the response arrives, so populate their
232            // model with setChildNodes first (as Chromium does).
233            c.events.append(&mut messages);
234            messages = std::mem::take(&mut c.events);
235        } else {
236            messages.append(&mut c.events);
237        }
238        self.connections.insert(connection, c);
239        messages
240    }
241    fn attach(&mut self, c: &mut Connection, target: &str, parent: &str) -> Result<String> {
242        if c.sessions.len() + c.browser_sessions.len() >= 64 {
243            return Err(Error::server("Session limit reached"));
244        }
245        let root = self
246            .targets
247            .get(target)
248            .and_then(|t| t.root.upgrade())
249            .ok_or_else(|| Error::server("Unknown target"))?;
250        let session = format!("session-{}", self.next_session);
251        self.next_session += 1;
252        c.sessions.insert(
253            session.clone(),
254            (target.into(), CdpSession::new(&root, target)),
255        );
256        c.parents.insert(session.clone(), parent.into());
257        c.events.push(with_session(json!({"method":"Target.attachedToTarget","params":{"sessionId":session,"targetInfo":self.info(target)?,"waitingForDebugger":false}}),parent));
258        Ok(session)
259    }
260    fn browser_command(
261        &mut self,
262        c: &mut Connection,
263        method: &str,
264        p: &Value,
265        parent: &str,
266    ) -> Result<Value> {
267        match method {
268            "Browser.getVersion" => Ok(
269                json!({"protocolVersion":"1.3","product":concat!("FerroUI/",env!("CARGO_PKG_VERSION")),"revision":"0","userAgent":"FerroUI","jsVersion":"0"}),
270            ),
271            "Target.getBrowserContexts" => Ok(json!({"browserContextIds":[]})),
272            "Target.getTargets" => Ok(
273                json!({"targetInfos":self.targets.keys().filter_map(|id|self.info(id).ok()).collect::<Vec<_>>()}),
274            ),
275            "Target.getTargetInfo" => {
276                if let Some(id) = p["targetId"].as_str().or(c.direct.as_deref()) {
277                    Ok(json!({"targetInfo":self.info(id)?}))
278                } else {
279                    Ok(
280                        json!({"targetInfo":{"targetId":"browser","type":"browser","title":"FerroUI","url":"","attached":true,"canAccessOpener":false}}),
281                    )
282                }
283            }
284            "Target.setDiscoverTargets" => {
285                c.discover = p["discover"]
286                    .as_bool()
287                    .ok_or_else(|| Error::params("discover must be boolean"))?;
288                c.known.clear();
289                self.update_connection(c);
290                Ok(json!({}))
291            }
292            "Target.setAutoAttach" => {
293                let auto_attach = p["autoAttach"]
294                    .as_bool()
295                    .ok_or_else(|| Error::params("autoAttach must be boolean"))?;
296                if auto_attach && p["flatten"] != true {
297                    return Err(Error::params("Only flattened sessions are supported"));
298                }
299                // On the socket of one page, auto-attach is about that page's own sub-targets,
300                // which a window does not have; only the browser socket attaches the windows.
301                c.auto_attach = auto_attach && c.direct.is_none();
302                self.update_connection(c);
303                Ok(json!({}))
304            }
305            "Target.attachToBrowserTarget" => {
306                if c.sessions.len() + c.browser_sessions.len() >= 64 {
307                    return Err(Error::server("Session limit reached"));
308                }
309                let session = format!("browser-session-{}", self.next_session);
310                self.next_session += 1;
311                c.browser_sessions.insert(session.clone());
312                Ok(json!({"sessionId":session}))
313            }
314            "Target.attachToTarget" => {
315                if p["flatten"] != true {
316                    return Err(Error::params("Only flattened sessions are supported"));
317                }
318                Ok(json!({"sessionId":self.attach(c,string(p,"targetId")?,parent)?}))
319            }
320            "Target.detachFromTarget" => {
321                let id = string(p, "sessionId")?;
322                if c.browser_sessions.remove(id) {
323                    let children: Vec<_> = c
324                        .parents
325                        .iter()
326                        .filter(|(_, p)| p.as_str() == id)
327                        .map(|(s, _)| s.clone())
328                        .collect();
329                    for child in children {
330                        c.sessions.remove(&child);
331                        c.parents.remove(&child);
332                    }
333                    return Ok(json!({}));
334                }
335                let (target, _) = c
336                    .sessions
337                    .remove(id)
338                    .ok_or_else(|| Error::server("Unknown session"))?;
339                let parent = c.parents.remove(id).unwrap_or_default();
340                c.events.push(with_session(json!({"method":"Target.detachedFromTarget","params":{"sessionId":id,"targetId":target}}),&parent));
341                Ok(json!({}))
342            }
343            "Browser.setDownloadBehavior" => Ok(json!({})),
344            _ => Err(Error::method(method)),
345        }
346    }
347    fn update_connection(&mut self, c: &mut Connection) {
348        let current: HashSet<_> = self.targets.keys().cloned().collect();
349        if c.discover {
350            for id in current.difference(&c.known) {
351                if let Ok(info) = self.info(id) {
352                    c.events.push(
353                        json!({"method":"Target.targetCreated","params":{"targetInfo":info}}),
354                    );
355                }
356            }
357            for id in c.known.difference(&current) {
358                c.events
359                    .push(json!({"method":"Target.targetDestroyed","params":{"targetId":id}}));
360            }
361        }
362        c.known = current;
363        let removed: Vec<_> = c
364            .sessions
365            .iter()
366            .filter(|(_, (id, _))| !self.targets.contains_key(id))
367            .map(|(s, (t, _))| (s.clone(), t.clone()))
368            .collect();
369        for (s, t) in removed {
370            c.sessions.remove(&s);
371            let parent = c.parents.remove(&s).unwrap_or_default();
372            c.events.push(with_session(
373                json!({"method":"Target.detachedFromTarget","params":{"sessionId":s,"targetId":t}}),
374                &parent,
375            ));
376        }
377        if c.auto_attach {
378            let ids: Vec<_> = self
379                .targets
380                .keys()
381                .filter(|id| !c.sessions.values().any(|(t, _)| t == *id))
382                .cloned()
383                .collect();
384            for id in ids {
385                let _ = self.attach(c, &id, "");
386            }
387        }
388    }
389    pub fn poll(&mut self, connection: u64) -> Vec<Value> {
390        self.refresh();
391        let Some(mut c) = self.connections.remove(&connection) else {
392            return vec![];
393        };
394        self.update_connection(&mut c);
395        for (session, (_, s)) in &mut c.sessions {
396            for event in s.poll() {
397                c.events.push(with_session(event, session));
398            }
399            for entry in LogCapture::since(&mut s.log_cursor)
400                .iter()
401                .filter(|e| e.level >= LogEventLevel::Information)
402                .map(log_entry)
403            {
404                if s.enabled.contains("Log") {
405                    c.events.push(with_session(
406                        json!({"method":"Log.entryAdded","params":{"entry":entry}}),
407                        session,
408                    ));
409                }
410                if s.enabled.contains("Runtime") {
411                    c.events.push(with_session(json!({"method":"Runtime.consoleAPICalled","params":{"type":if entry["level"]=="warning" {"warning"} else if entry["level"]=="error" {"error"} else {"log"},"args":[{"type":"string","value":entry["text"]}],"executionContextId":1,"timestamp":entry["timestamp"]}}),session));
412                }
413            }
414        }
415        let events = std::mem::take(&mut c.events);
416        self.connections.insert(connection, c);
417        events
418    }
419}
420/// A `Log.LogEntry` of CDP.
421fn log_entry(entry: &LogEntry) -> Value {
422    let level = match entry.level {
423        LogEventLevel::Warning => "warning",
424        LogEventLevel::Error | LogEventLevel::Fatal => "error",
425        _ => "info",
426    };
427    json!({"source":"other","level":level,"text":format!("[{}] {}",entry.area,entry.message()),"timestamp":entry.timestamp})
428}
429fn with_session(mut value: Value, session: &str) -> Value {
430    if !session.is_empty() {
431        value["sessionId"] = json!(session);
432    }
433    value
434}