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
33pub 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 _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 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, ¶ms);
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, ¶ms, 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 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 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(¤t) {
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}
420fn 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}