use std::{collections::BTreeMap, fs, path::PathBuf, time::Instant}; use ely_domain::{BrowserTab, ProfileId, TabId}; use crate::services::{ ProfileDataMode, servo_live::{ServoLiveClient, ServoLiveEnsureRequest, ServoLiveSitePermission}, servo_profile_data::{default_profile_data_root, profile_data_dir, transient_profile_data_dir}, }; use super::{ web_surface_frame::WebSurfaceFrame, web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize}, web_surface_permissions::WebSurfaceSitePermission, web_surface_state::WebSurfacePendingInput, web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, WorkerResponse}, }; pub(super) struct WebSurfaceRuntime { workers: BTreeMap, sessions: BTreeMap, client_factory: LiveRuntimeClientFactory, } impl WebSurfaceRuntime { pub(super) fn new() -> Self { Self { workers: BTreeMap::new(), sessions: BTreeMap::new(), client_factory: new_servo_live_client, } } #[cfg(test)] pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self { Self { workers: BTreeMap::new(), sessions: BTreeMap::new(), client_factory } } pub(super) fn ensure_tab( &mut self, tab: &BrowserTab, size: WebSurfaceSize, profile_data_mode: ProfileDataMode, permissions: &[WebSurfaceSitePermission], input: WebSurfacePendingInput, ) -> Result { let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode); self.ensure_worker(scope.clone())?; let requested_url = tab.url().as_str().to_string(); let zoom_percent = tab.zoom_percent(); let enqueued_at = input.enqueued_at; let input_kind = pending_input_kind(&input); let (scroll_delta_x, scroll_delta_y, scroll_point_x, scroll_point_y) = scroll_wire_fields(input.scroll_delta, input.scroll_point)?; let user_navigation_input = input_requests_history_navigation(&input); let next_scroll_offset = input.scroll_offset; let started_loading = { let session = session_for_scope(&mut self.sessions, tab.id(), scope.clone()); let started_loading = session.started_loading(&requested_url, size, zoom_percent); if started_loading { session.pending_user_navigation = false; } if user_navigation_input { session.pending_user_navigation = true; } session.requested_url = requested_url.clone(); session.size = size; session.zoom_percent = zoom_percent; session.scroll_offset = next_scroll_offset; started_loading }; let request = ServoLiveEnsureRequest { tab_id: tab.id().as_str().to_string(), profile_id: tab.profile_id().as_str().to_string(), url: requested_url.clone(), width: size.width, height: size.height, page_zoom_percent: zoom_percent, device_pixel_ratio: size.device_pixel_ratio_f32(), scroll_delta_x, scroll_delta_y, scroll_point_x, scroll_point_y, click_x: input.click_point.map(|point| point.x()), click_y: input.click_point.map(|point| point.y()), hover_x: input.hover_point.map(|point| point.x()), hover_y: input.hover_point.map(|point| point.y()), typed_text: input.typed_text, site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(), }; let Some(scoped) = self.workers.get(&scope) else { return Err("Servo worker was created but is no longer registered".to_string()); }; scoped.worker.submit_ensure(request); log_ensure_submitted(tab, size, input_kind, enqueued_at, started_loading); Ok(WebSurfaceEnsureResult { requested_url, started_loading }) } pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec { // Submit a Poll for every visible tab whose session is live so // animations / JS-driven content keep advancing without user // input. The worker coalesces — a Poll never overrides a // pending Ensure — so this stays cheap even at 120 Hz. for tab_id in visible_tab_ids { let Some(session) = self.sessions.get(tab_id) else { continue; }; let Some(scoped) = self.workers.get(&session.scope) else { continue; }; scoped.worker.submit_poll(tab_id.as_str().to_string()); } let mut frames = Vec::new(); let mut dead_scopes = Vec::new(); let scopes: Vec = self.workers.keys().cloned().collect(); for scope in scopes { let responses = self .workers .get(&scope) .map(|scoped| scoped.worker.drain_responses()) .unwrap_or_default(); for response in responses { match response { WorkerResponse::Frame { tab_id, frame } => { let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else { continue; }; let session = match self.sessions.get_mut(&tab_id_obj) { Some(session) => session, None => continue, }; let requested_url = session.requested_url.clone(); let scroll_offset = session.scroll_offset; let zoom_percent = session.zoom_percent; match WebSurfaceFrame::from_live_frame( requested_url.clone(), scroll_offset, zoom_percent, frame, ) { Ok(frame) => { let url_change = session.url_change_for( &tab_id_obj, requested_url.as_str(), &frame, ); frames.push(WebSurfaceRuntimeFrame::Ready { tab_id: tab_id_obj, frame: Box::new(frame), url_change, }); } Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message: error.to_string(), }), } } WorkerResponse::Failed { tab_id, message } => { let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else { continue; }; frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message }); } WorkerResponse::SidecarExited => dead_scopes.push(scope.clone()), } } } for scope in dead_scopes { self.workers.remove(&scope); } frames } pub(super) fn close_tab(&mut self, tab_id: &TabId) { let Some(session) = self.sessions.remove(tab_id) else { return; }; if let Some(scoped) = self.workers.get(&session.scope) { scoped.worker.submit_close(tab_id.as_str().to_string()); } } fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> { if self.workers.contains_key(&scope) { return Ok(()); } let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?; let client = (self.client_factory)(config_dir)?; let worker = LiveRuntimeWorker::new(client)?; self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_dir }); Ok(()) } fn lookup_session_tab_id(&self, tab_id: &str) -> Option { self.sessions.keys().find(|key| key.as_str() == tab_id).cloned() } #[cfg(test)] pub(super) fn client_count_for_test(&self) -> usize { self.workers.len() } #[cfg(test)] pub(super) fn session_scope_for_test(&self, tab_id: &TabId) -> Option<&WebSurfaceRuntimeScope> { self.sessions.get(tab_id).map(|session| &session.scope) } #[cfg(test)] pub(super) fn flush_for_test(&self) { for scoped in self.workers.values() { scoped.worker.wait_until_idle(); } } } impl Drop for WebSurfaceRuntime { fn drop(&mut self) { let transient_profile_data_dirs = self .workers .values() .filter_map(|scoped| scoped.transient_profile_data_dir.clone()) .collect::>(); self.workers.clear(); for path in transient_profile_data_dirs { let _ = fs::remove_dir_all(path); } } } pub(super) type LiveRuntimeClientFactory = fn(PathBuf) -> Result, String>; fn new_servo_live_client(config_dir: PathBuf) -> Result, String> { ServoLiveClient::new(config_dir) .map(|client| Box::new(client) as Box) .map_err(|error| error.to_string()) } struct ScopedWorker { worker: LiveRuntimeWorker, transient_profile_data_dir: Option, } #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub(super) struct WebSurfaceRuntimeScope { profile_id: ProfileId, profile_data_mode: ProfileDataMode, } impl WebSurfaceRuntimeScope { pub(super) fn new(profile_id: ProfileId, profile_data_mode: ProfileDataMode) -> Self { Self { profile_id, profile_data_mode } } } #[derive(Clone)] pub(super) struct WebSurfaceSession { pub(super) scope: WebSurfaceRuntimeScope, pub(super) requested_url: String, pub(super) size: WebSurfaceSize, pub(super) zoom_percent: u16, pub(super) scroll_offset: WebSurfaceScrollOffset, pub(super) pending_user_navigation: bool, } impl WebSurfaceSession { fn new(scope: WebSurfaceRuntimeScope) -> Self { Self { scope, requested_url: String::new(), size: WebSurfaceSize::default(), zoom_percent: 0, scroll_offset: WebSurfaceScrollOffset::default(), pending_user_navigation: false, } } fn started_loading( &self, requested_url: &str, size: WebSurfaceSize, zoom_percent: u16, ) -> bool { self.requested_url != requested_url || self.size != size || self.zoom_percent != zoom_percent } fn url_change_for( &mut self, tab_id: &TabId, requested_url: &str, frame: &WebSurfaceFrame, ) -> Option { let loaded_url = frame.loaded_url()?; if loaded_url == requested_url { return None; } let kind = if self.pending_user_navigation { WebSurfaceUrlChangeKind::UserInitiated } else { WebSurfaceUrlChangeKind::Observed }; self.pending_user_navigation = false; Some(WebSurfaceUrlChange { tab_id: tab_id.clone(), loaded_url: loaded_url.to_string(), kind, }) } } pub(super) struct WebSurfaceEnsureResult { pub(super) requested_url: String, pub(super) started_loading: bool, } pub(super) enum WebSurfaceRuntimeFrame { Ready { tab_id: TabId, frame: Box, url_change: Option }, Failed { tab_id: TabId, message: String }, } #[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct WebSurfaceUrlChange { pub(super) tab_id: TabId, pub(super) loaded_url: String, pub(super) kind: WebSurfaceUrlChangeKind, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum WebSurfaceUrlChangeKind { UserInitiated, Observed, } fn config_dir_for_scope( scope: &WebSurfaceRuntimeScope, ) -> Result<(PathBuf, Option), String> { match scope.profile_data_mode { ProfileDataMode::Persistent => { let root = default_profile_data_root() .ok_or_else(|| "Profile data root is unavailable".to_string())?; let config_dir = profile_data_dir(&root, &scope.profile_id); fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?; Ok((config_dir, None)) } ProfileDataMode::Transient => { let config_dir = transient_profile_data_dir(&scope.profile_id).map_err(|error| error.to_string())?; fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?; Ok((config_dir.clone(), Some(config_dir))) } } } pub(super) fn session_for_scope<'a>( sessions: &'a mut BTreeMap, tab_id: &TabId, scope: WebSurfaceRuntimeScope, ) -> &'a mut WebSurfaceSession { let session = sessions.entry(tab_id.clone()).or_insert_with(|| WebSurfaceSession::new(scope.clone())); if session.scope != scope { *session = WebSurfaceSession::new(scope); } session } fn scroll_wire_fields( delta: Option, point: Option, ) -> Result<(i32, i32, Option, Option), String> { match delta { Some(delta) => { let point = point .ok_or_else(|| "Servo scroll input is missing a viewport point".to_string())?; Ok((delta.x(), delta.y(), Some(point.x()), Some(point.y()))) } None => Ok((0, 0, None, None)), } } fn input_requests_history_navigation(input: &WebSurfacePendingInput) -> bool { input.click_point.is_some() || input.typed_text.as_deref().is_some_and(|text| text.contains('\n')) } fn pending_input_kind(input: &WebSurfacePendingInput) -> &'static str { if input.scroll_delta.is_some() { "scroll" } else if input.click_point.is_some() { "click" } else if input.typed_text.is_some() { "text" } else if input.hover_point.is_some() { "hover" } else { "idle" } } fn log_ensure_submitted( tab: &BrowserTab, size: WebSurfaceSize, input_kind: &'static str, enqueued_at: Option, started_loading: bool, ) { if input_kind == "idle" && !started_loading { return; } let queued_us = enqueued_at.map(|started_at| started_at.elapsed().as_micros()); tracing::info!( target: "ely::web_surface::latency", tab_id = %tab.id().as_str(), url = %tab.url().as_str(), input_kind, queued_us, started_loading, width = size.width, height = size.height, device_pixel_ratio = size.device_pixel_ratio_f32(), "web_surface_ensure_submitted", ); } impl From<&WebSurfaceSitePermission> for ServoLiveSitePermission { fn from(permission: &WebSurfaceSitePermission) -> Self { Self::new( permission.origin().as_str(), permission.feature().as_str(), permission.decision(), ) } } #[cfg(test)] #[path = "web_surface_runtime_tests.rs"] mod tests;