diff --git a/crates/ely_app/src/services/servo_profile_data.rs b/crates/ely_app/src/services/servo_profile_data.rs index ba577bf..4f297f6 100644 --- a/crates/ely_app/src/services/servo_profile_data.rs +++ b/crates/ely_app/src/services/servo_profile_data.rs @@ -31,7 +31,7 @@ pub(crate) fn transient_profile_data_dir( ))) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub(crate) enum ProfileDataMode { Persistent, Transient, diff --git a/crates/ely_app/src/shell/web_surface_runtime.rs b/crates/ely_app/src/shell/web_surface_runtime.rs index ae1bdfc..934bd09 100644 --- a/crates/ely_app/src/shell/web_surface_runtime.rs +++ b/crates/ely_app/src/shell/web_surface_runtime.rs @@ -16,13 +16,23 @@ use super::{ }; pub(super) struct WebSurfaceRuntime { - state: RuntimeState, + clients: BTreeMap, sessions: BTreeMap, + client_factory: LiveRuntimeClientFactory, } impl WebSurfaceRuntime { pub(super) fn new() -> Self { - Self { state: RuntimeState::Empty, sessions: BTreeMap::new() } + Self { + clients: BTreeMap::new(), + sessions: BTreeMap::new(), + client_factory: new_servo_live_client, + } + } + + #[cfg(test)] + fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self { + Self { clients: BTreeMap::new(), sessions: BTreeMap::new(), client_factory } } pub(super) fn ensure_tab( @@ -38,27 +48,25 @@ impl WebSurfaceRuntime { let requested_url = tab.url().as_str().to_string(); let zoom_percent = tab.zoom_percent(); - let (state, sessions) = (&mut self.state, &mut self.sessions); - let RuntimeState::Ready { scope: active_scope, client, .. } = state else { - return Err("Servo live runtime is unavailable".to_string()); - }; - if active_scope != &scope { - return Err(active_scope.error_for(&scope)); - } 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 session = sessions.entry(tab.id().clone()).or_insert_with(WebSurfaceSession::default); let next_scroll_offset = input.scroll_offset; - 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; - } - let frame = client + 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; + } + started_loading + }; + + let frame = self + .client_for_scope(&scope)? .ensure(ServoLiveEnsureRequest { tab_id: tab.id().as_str().to_string(), profile_id: tab.profile_id().as_str().to_string(), @@ -77,8 +85,7 @@ impl WebSurfaceRuntime { hover_y: input.hover_point.map(|point| point.y()), typed_text: input.typed_text, site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(), - }) - .map_err(|error| error.to_string())? + })? .map(|frame| { WebSurfaceFrame::from_live_frame( requested_url.clone(), @@ -89,10 +96,13 @@ impl WebSurfaceRuntime { .map_err(|error| error.to_string()) }) .transpose()?; - let url_change = frame - .as_ref() - .and_then(|frame| session.url_change_for(tab.id(), requested_url.as_str(), frame)); + let url_change = frame.as_ref().and_then(|frame| { + self.sessions + .get_mut(tab.id()) + .and_then(|session| session.url_change_for(tab.id(), requested_url.as_str(), frame)) + }); + let session = session_for_scope(&mut self.sessions, tab.id(), scope); session.requested_url = requested_url.clone(); session.size = size; session.zoom_percent = zoom_percent; @@ -102,43 +112,37 @@ impl WebSurfaceRuntime { } pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec { - let (state, sessions) = (&mut self.state, &mut self.sessions); - let RuntimeState::Ready { client, .. } = state else { - return Vec::new(); - }; - let mut frames = Vec::new(); - for (tab_id, session) in sessions { - if !visible_tab_ids.iter().any(|visible_tab_id| visible_tab_id == tab_id) { - continue; - } - match client.poll(tab_id.as_str().to_string()) { + for job in visible_poll_jobs(&self.sessions, visible_tab_ids) { + let result = match self.clients.get_mut(&job.scope) { + Some(client) => client.client.poll(job.tab_id.as_str().to_string()), + None => Err(missing_runtime_message(&job.scope)), + }; + match result { Ok(Some(frame)) => match WebSurfaceFrame::from_live_frame( - session.requested_url.clone(), - session.scroll_offset, - session.zoom_percent, + job.requested_url.clone(), + job.scroll_offset, + job.zoom_percent, frame, ) { Ok(frame) => { - let requested_url = session.requested_url.clone(); - let url_change = - session.url_change_for(tab_id, requested_url.as_str(), &frame); + let url_change = self.sessions.get_mut(&job.tab_id).and_then(|session| { + session.url_change_for(&job.tab_id, job.requested_url.as_str(), &frame) + }); frames.push(WebSurfaceRuntimeFrame::Ready { - tab_id: tab_id.clone(), + tab_id: job.tab_id, frame: Box::new(frame), url_change, }) } Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed { - tab_id: tab_id.clone(), + tab_id: job.tab_id, message: error.to_string(), }), }, Ok(None) => {} - Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed { - tab_id: tab_id.clone(), - message: error.to_string(), - }), + Err(error) => frames + .push(WebSurfaceRuntimeFrame::Failed { tab_id: job.tab_id, message: error }), } } @@ -146,38 +150,82 @@ impl WebSurfaceRuntime { } fn ensure_runtime(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> { - match &self.state { - RuntimeState::Empty => { - let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?; - let client = ServoLiveClient::new(config_dir).map_err(|error| error.to_string())?; - self.state = RuntimeState::Ready { scope, client, transient_profile_data_dir }; - Ok(()) - } - RuntimeState::Ready { scope: active_scope, .. } if active_scope == &scope => Ok(()), - RuntimeState::Ready { scope: active_scope, .. } => Err(active_scope.error_for(&scope)), + if self.clients.contains_key(&scope) { + return Ok(()); } + let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?; + let client = (self.client_factory)(config_dir)?; + self.clients.insert(scope, ScopedRuntimeClient { client, transient_profile_data_dir }); + Ok(()) + } + + fn client_for_scope( + &mut self, + scope: &WebSurfaceRuntimeScope, + ) -> Result<&mut dyn LiveRuntimeClient, String> { + match self.clients.get_mut(scope) { + Some(client) => Ok(client.client.as_mut()), + None => Err(missing_runtime_message(scope)), + } + } + + #[cfg(test)] + fn client_count_for_test(&self) -> usize { + self.clients.len() + } + + #[cfg(test)] + fn session_scope_for_test(&self, tab_id: &TabId) -> Option<&WebSurfaceRuntimeScope> { + self.sessions.get(tab_id).map(|session| &session.scope) } } impl Drop for WebSurfaceRuntime { fn drop(&mut self) { - let RuntimeState::Ready { transient_profile_data_dir: Some(path), .. } = &self.state else { - return; - }; - let _ = fs::remove_dir_all(path); + let transient_profile_data_dirs = self + .clients + .values() + .filter_map(|client| client.transient_profile_data_dir.clone()) + .collect::>(); + self.clients.clear(); + for path in transient_profile_data_dirs { + let _ = fs::remove_dir_all(path); + } } } -enum RuntimeState { - Empty, - Ready { - scope: WebSurfaceRuntimeScope, - client: ServoLiveClient, - transient_profile_data_dir: Option, - }, +type LiveRuntimeClientFactory = fn(PathBuf) -> Result, String>; + +trait LiveRuntimeClient { + fn ensure(&mut self, request: ServoLiveEnsureRequest) -> Result, String>; + + fn poll(&mut self, tab_id: String) -> Result, String>; } -#[derive(Clone, Debug, Eq, PartialEq)] +type WebLiveFrame = crate::services::servo_live::ServoLiveFrame; + +impl LiveRuntimeClient for ServoLiveClient { + fn ensure(&mut self, request: ServoLiveEnsureRequest) -> Result, String> { + ServoLiveClient::ensure(self, request).map_err(|error| error.to_string()) + } + + fn poll(&mut self, tab_id: String) -> Result, String> { + ServoLiveClient::poll(self, tab_id).map_err(|error| error.to_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 ScopedRuntimeClient { + client: Box, + transient_profile_data_dir: Option, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] struct WebSurfaceRuntimeScope { profile_id: ProfileId, profile_data_mode: ProfileDataMode, @@ -187,20 +235,11 @@ impl WebSurfaceRuntimeScope { fn new(profile_id: ProfileId, profile_data_mode: ProfileDataMode) -> Self { Self { profile_id, profile_data_mode } } - - fn error_for(&self, requested: &Self) -> String { - format!( - "Servo live runtime is already attached to profile {} ({:?}); requested profile {} ({:?})", - self.profile_id.as_str(), - self.profile_data_mode, - requested.profile_id.as_str(), - requested.profile_data_mode - ) - } } -#[derive(Clone, Default)] +#[derive(Clone)] struct WebSurfaceSession { + scope: WebSurfaceRuntimeScope, requested_url: String, size: WebSurfaceSize, zoom_percent: u16, @@ -209,6 +248,17 @@ struct WebSurfaceSession { } 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, @@ -245,6 +295,15 @@ impl WebSurfaceSession { } } +#[derive(Clone)] +struct WebSurfacePollJob { + tab_id: TabId, + scope: WebSurfaceRuntimeScope, + requested_url: String, + scroll_offset: WebSurfaceScrollOffset, + zoom_percent: u16, +} + pub(super) struct WebSurfaceEnsureResult { pub(super) requested_url: String, pub(super) started_loading: bool, @@ -290,6 +349,46 @@ fn config_dir_for_scope( } } +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 visible_poll_jobs( + sessions: &BTreeMap, + visible_tab_ids: &[TabId], +) -> Vec { + sessions + .iter() + .filter(|(tab_id, _)| { + visible_tab_ids.iter().any(|visible_tab_id| visible_tab_id == *tab_id) + }) + .map(|(tab_id, session)| WebSurfacePollJob { + tab_id: tab_id.clone(), + scope: session.scope.clone(), + requested_url: session.requested_url.clone(), + scroll_offset: session.scroll_offset, + zoom_percent: session.zoom_percent, + }) + .collect() +} + +fn missing_runtime_message(scope: &WebSurfaceRuntimeScope) -> String { + format!( + "Servo live runtime is unavailable for profile {} ({:?})", + scope.profile_id.as_str(), + scope.profile_data_mode + ) +} + fn scroll_wire_fields( delta: Option, point: Option, @@ -318,3 +417,7 @@ impl From<&WebSurfaceSitePermission> for ServoLiveSitePermission { ) } } + +#[cfg(test)] +#[path = "web_surface_runtime_tests.rs"] +mod tests; diff --git a/crates/ely_app/src/shell/web_surface_runtime_tests.rs b/crates/ely_app/src/shell/web_surface_runtime_tests.rs new file mode 100644 index 0000000..dce7bbe --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_runtime_tests.rs @@ -0,0 +1,117 @@ +use std::collections::BTreeMap; + +use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText}; + +use crate::{ + services::ProfileDataMode, + shell::{ + web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize}, + web_surface_state::WebSurfacePendingInput, + }, +}; + +use super::*; + +#[test] +fn runtime_keeps_independent_clients_for_profile_scopes() -> Result<(), String> { + let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory); + let first_profile = ProfileId::new(); + let second_profile = ProfileId::new(); + let first_tab = web_tab(TabId::new(), first_profile.clone(), "https://example.com/first")?; + let second_tab = web_tab(TabId::new(), second_profile.clone(), "https://example.com/second")?; + + runtime.ensure_tab( + &first_tab, + surface_size(), + ProfileDataMode::Transient, + &[], + pending_input(), + )?; + runtime.ensure_tab( + &second_tab, + surface_size(), + ProfileDataMode::Transient, + &[], + pending_input(), + )?; + runtime.ensure_tab( + &first_tab, + surface_size(), + ProfileDataMode::Transient, + &[], + pending_input(), + )?; + + assert_eq!(runtime.client_count_for_test(), 2); + assert_eq!( + runtime.session_scope_for_test(first_tab.id()), + Some(&WebSurfaceRuntimeScope::new(first_profile, ProfileDataMode::Transient)), + ); + assert_eq!( + runtime.session_scope_for_test(second_tab.id()), + Some(&WebSurfaceRuntimeScope::new(second_profile, ProfileDataMode::Transient)), + ); + Ok(()) +} + +#[test] +fn session_scope_change_resets_tab_state() { + let tab_id = TabId::new(); + let first_scope = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Persistent); + let second_scope = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient); + let mut sessions = BTreeMap::new(); + + let session = session_for_scope(&mut sessions, &tab_id, first_scope); + session.requested_url = "https://example.com/old".to_string(); + session.size = surface_size(); + session.zoom_percent = 150; + session.scroll_offset = WebSurfaceScrollOffset::default(); + session.pending_user_navigation = true; + + let session = session_for_scope(&mut sessions, &tab_id, second_scope.clone()); + + assert_eq!(session.scope, second_scope); + assert_eq!(session.requested_url, ""); + assert_eq!(session.size, WebSurfaceSize::default()); + assert_eq!(session.zoom_percent, 0); + assert_eq!(session.scroll_offset, WebSurfaceScrollOffset::default()); + assert!(!session.pending_user_navigation); +} + +struct FakeLiveRuntimeClient; + +impl LiveRuntimeClient for FakeLiveRuntimeClient { + fn ensure(&mut self, _request: ServoLiveEnsureRequest) -> Result, String> { + Ok(None) + } + + fn poll(&mut self, _tab_id: String) -> Result, String> { + Ok(None) + } +} + +fn fake_client_factory( + _config_dir: std::path::PathBuf, +) -> Result, String> { + Ok(Box::new(FakeLiveRuntimeClient)) +} + +fn web_tab(tab_id: TabId, profile_id: ProfileId, url: &str) -> Result { + let url = UrlText::parse(url).map_err(|error| error.to_string())?; + Ok(BrowserTab::new(tab_id, SpaceId::new(), profile_id, "Web", url)) +} + +fn surface_size() -> WebSurfaceSize { + WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 } +} + +fn pending_input() -> WebSurfacePendingInput { + WebSurfacePendingInput { + scroll_offset: WebSurfaceScrollOffset::default(), + scroll_delta: None, + scroll_point: None, + click_point: None, + hover_point: None, + typed_text: None, + } +}