diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs index 4d55409..9916b63 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs @@ -23,6 +23,8 @@ mod live; mod live_output; #[path = "ely_servo_sidecar/live_protocol.rs"] mod live_protocol; +#[path = "ely_servo_sidecar/live_session.rs"] +mod live_session; #[path = "ely_servo_sidecar/perf.rs"] mod perf; #[path = "ely_servo_sidecar/report.rs"] diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs index aa715b4..5e49dcf 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs @@ -5,13 +5,11 @@ use std::{ time::Instant, }; -use ely_domain::{DEFAULT_ZOOM_PERCENT, ProfileId, TabId, UrlText}; +use ely_domain::{ProfileId, TabId, UrlText}; #[cfg(all(feature = "hardware-render", target_os = "macos"))] use ely_servo_host::WebViewState; use ely_servo_host::{ - IOSurfaceIdentity, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, - NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, - RenderingContextKind, ResizeRequest, ScrollRequest, ServoHost, ServoSurfaceSize, + IOSurfaceIdentity, NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize, SoftwareServoHost, }; @@ -20,8 +18,9 @@ use super::args::LiveArgs; use super::iosurface_mach::{IOSurfaceMachSender, send_surface_port_if_needed}; use super::live_output::{populate_surface_fields, write_outcome}; pub(super) use super::live_protocol::LiveSidecarError; -use super::live_protocol::{ - LiveFrameReport, LiveOutcome, LiveRequest, LiveSitePermission, PartialFrameTimings, +use super::live_protocol::{LiveFrameReport, LiveOutcome, LiveRequest, PartialFrameTimings}; +use super::live_session::{ + LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, ensure_session, }; use super::perf::{FramePerfAggregator, FramePerfSummary, elapsed_ns}; @@ -195,167 +194,6 @@ fn handle_request( } } -fn ensure_session<'a>( - host: &mut SoftwareServoHost, - sessions: &'a mut HashMap, - key: String, - tab_id: &TabId, - profile_id: &ProfileId, - width: u32, - height: u32, -) -> Result<&'a mut LiveSession, LiveSidecarError> { - if !sessions.contains_key(&key) { - let webview_id = host.create_webview_with_size( - tab_id.clone(), - profile_id.clone(), - ServoSurfaceSize::new(width, height), - )?; - sessions.insert(key.clone(), LiveSession::new(webview_id, width, height)); - } - - sessions.get_mut(&key).ok_or(LiveSidecarError::SessionUnavailable) -} - -fn apply_layout( - host: &mut SoftwareServoHost, - session: &mut LiveSession, - width: u32, - height: u32, - page_zoom_percent: u16, - device_pixel_ratio: f32, -) -> Result { - let mut changed = false; - // Push the device pixel ratio BEFORE resize. Servo's WebView - // defaults hidpi to 1.0; without this the first layout treats - // physical-pixel viewport widths as CSS-pixel widths and the page - // lays out half the size you'd expect on a Retina display. The - // sidecar-side `LiveSession::hidpi_scale_milli` keeps a u32 of - // (scale × 1000) so f32 jitter from JSON parsing doesn't churn - // the setter every frame. - let hidpi_scale_milli = encode_hidpi_scale_milli(device_pixel_ratio); - if session.hidpi_scale_milli != hidpi_scale_milli { - host.set_hidpi_scale(ely_servo_host::HidpiScaleRequest { - webview_id: session.webview_id.clone(), - scale_factor: hidpi_scale_milli_to_f32(hidpi_scale_milli), - })?; - session.hidpi_scale_milli = hidpi_scale_milli; - changed = true; - } - - if session.width != width || session.height != height { - host.resize(ResizeRequest { webview_id: session.webview_id.clone(), width, height })?; - session.width = width; - session.height = height; - changed = true; - } - - if session.page_zoom_percent != page_zoom_percent { - host.set_page_zoom(PageZoomRequest { - webview_id: session.webview_id.clone(), - zoom_factor: f32::from(page_zoom_percent) / 100.0, - })?; - session.page_zoom_percent = page_zoom_percent; - changed = true; - } - - Ok(changed) -} - -fn encode_hidpi_scale_milli(scale: f32) -> u32 { - if !scale.is_finite() || scale <= 0.0 { - return 1_000; - } - let scaled = (scale * 1_000.0).round(); - scaled.clamp(500.0, 5_000.0) as u32 -} - -fn hidpi_scale_milli_to_f32(milli: u32) -> f32 { - milli as f32 / 1_000.0 -} - -fn apply_permissions( - host: &mut SoftwareServoHost, - session: &LiveSession, - profile_id: &ProfileId, - permissions: Vec, -) -> Result<(), LiveSidecarError> { - for permission in permissions { - host.set_permission( - PermissionRequest { - webview_id: session.webview_id.clone(), - profile_id: profile_id.clone(), - origin: ely_domain::SiteOrigin::parse(permission.origin)?, - feature: ely_domain::SitePermissionFeature::parse(permission.feature.as_str())?, - }, - PermissionDecision::from(ely_domain::SitePermissionDecision::parse( - permission.decision.as_str(), - )?), - )?; - } - - Ok(()) -} - -fn apply_input( - host: &mut SoftwareServoHost, - session: &mut LiveSession, - input: LiveInput, -) -> Result { - let mut changed = false; - if input.scroll_delta_x != 0 || input.scroll_delta_y != 0 { - let (point_x, point_y) = input.scroll_point()?; - host.scroll(ScrollRequest { - webview_id: session.webview_id.clone(), - delta_x: input.scroll_delta_x, - delta_y: input.scroll_delta_y, - point_x, - point_y, - })?; - session.scroll_x = positive_scroll_component(session.scroll_x, input.scroll_delta_x); - session.scroll_y = positive_scroll_component(session.scroll_y, input.scroll_delta_y); - changed = true; - } - - if let (Some(x), Some(y)) = (input.hover_x, input.hover_y) { - host.hover(MouseHoverRequest { webview_id: session.webview_id.clone(), x, y })?; - changed = true; - } - - if let (Some(x), Some(y)) = (input.click_x, input.click_y) { - host.click(MouseClickRequest { webview_id: session.webview_id.clone(), x, y })?; - changed = true; - } - - if let Some(text) = input.typed_text { - host.type_text(KeyboardTextRequest { webview_id: session.webview_id.clone(), text })?; - changed = true; - } - - Ok(changed) -} - -struct LiveInput { - scroll_delta_x: i32, - scroll_delta_y: i32, - scroll_point_x: Option, - scroll_point_y: Option, - click_x: Option, - click_y: Option, - hover_x: Option, - hover_y: Option, - typed_text: Option, -} - -impl LiveInput { - fn scroll_point(&self) -> Result<(u32, u32), LiveSidecarError> { - let point = match (self.scroll_point_x, self.scroll_point_y) { - (Some(x), Some(y)) => (x, y), - _ => return Err(LiveSidecarError::IncompleteScrollPoint), - }; - Ok(point) - } -} - fn poll_frame( host: &mut SoftwareServoHost, session: &mut LiveSession, @@ -457,77 +295,10 @@ fn hardware_snapshot_has_visible_document(snapshot: &ely_servo_host::WebViewSnap snapshot.title().is_some() || matches!(snapshot.state(), WebViewState::Complete) } -#[derive(Clone)] -struct LiveSession { - webview_id: ely_domain::WebViewId, - requested_url: String, - width: u32, - height: u32, - page_zoom_percent: u16, - /// Last hidpi factor pushed to Servo, encoded as `(scale × 1000)`. - /// Stored as a u32 so equality is cheap and stable across the - /// f32 jitter that JSON parsing can introduce. Init to 0 so the - /// first apply_layout call always pushes a real value. - hidpi_scale_milli: u32, - scroll_x: i32, - scroll_y: i32, - awaiting_visible_frame: bool, - /// Sticky for the lifetime of a single URL: flipped to `true` - /// the first time `poll_frame` sees a paint with real content - /// (non-white, non-empty) and reset to `false` on every navigate. - /// After it's `true`, the visible-content gate stops gating — - /// scroll/click/hover/type all return on the first - /// `has_pending_frame=true` (~3 ms) instead of waiting the full - /// `LIVE_FRAME_WAIT_TIMEOUT`. The gate stays armed for the - /// initial paint of each new URL so loading frames are still - /// skipped. - ever_visible_frame: bool, -} - -impl LiveSession { - fn new(webview_id: ely_domain::WebViewId, _width: u32, _height: u32) -> Self { - Self { - webview_id, - requested_url: String::new(), - width: 0, - height: 0, - page_zoom_percent: DEFAULT_ZOOM_PERCENT, - hidpi_scale_milli: 0, - scroll_x: 0, - scroll_y: 0, - awaiting_visible_frame: false, - ever_visible_frame: false, - } - } - - fn device_pixel_ratio(&self) -> f32 { - hidpi_scale_milli_to_f32(self.hidpi_scale_milli) - } -} - -fn positive_scroll_component(current: i32, delta: i32) -> i32 { - let value = i64::from(current) + i64::from(delta); - value.clamp(0, i64::from(i32::MAX)) as i32 -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn new_live_session_forces_first_resize_after_hidpi() { - let session = LiveSession::new(ely_domain::WebViewId::new(), 1280, 720); - - assert_ne!( - session.width, 1280, - "first apply_layout must resize after hidpi has been pushed", - ); - assert_ne!( - session.height, 720, - "first apply_layout must resize after hidpi has been pushed", - ); - } - #[test] fn awaiting_visible_frame_forces_paint_without_pending_flag() { assert!(should_paint_live_frame(false, true)); diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_session.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_session.rs new file mode 100644 index 0000000..d2dfc9d --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_session.rs @@ -0,0 +1,240 @@ +use std::collections::HashMap; + +use ely_domain::{DEFAULT_ZOOM_PERCENT, ProfileId, TabId}; +use ely_servo_host::{ + KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, PageZoomRequest, PermissionDecision, + PermissionRequest, ResizeRequest, ScrollRequest, ServoHost, ServoSurfaceSize, + SoftwareServoHost, +}; + +use super::live_protocol::{LiveSidecarError, LiveSitePermission}; + +#[derive(Clone)] +pub(super) struct LiveSession { + pub(super) webview_id: ely_domain::WebViewId, + pub(super) requested_url: String, + pub(super) width: u32, + pub(super) height: u32, + page_zoom_percent: u16, + /// Last hidpi factor pushed to Servo, encoded as `(scale × 1000)`. + /// Stored as a u32 so equality is cheap and stable across the + /// f32 jitter that JSON parsing can introduce. Init to 0 so the + /// first apply_layout call always pushes a real value. + hidpi_scale_milli: u32, + pub(super) scroll_x: i32, + pub(super) scroll_y: i32, + pub(super) awaiting_visible_frame: bool, + /// Sticky for the lifetime of a single URL: flipped to `true` + /// the first time `poll_frame` sees a paint with real content + /// (non-white, non-empty) and reset to `false` on every navigate. + /// After it's `true`, the visible-content gate stops gating: + /// scroll/click/hover/type all return on the first + /// `has_pending_frame=true` (~3 ms) instead of waiting the full + /// `LIVE_FRAME_WAIT_TIMEOUT`. The gate stays armed for the + /// initial paint of each new URL so loading frames are still + /// skipped. + pub(super) ever_visible_frame: bool, +} + +impl LiveSession { + fn new(webview_id: ely_domain::WebViewId, _width: u32, _height: u32) -> Self { + Self { + webview_id, + requested_url: String::new(), + width: 0, + height: 0, + page_zoom_percent: DEFAULT_ZOOM_PERCENT, + hidpi_scale_milli: 0, + scroll_x: 0, + scroll_y: 0, + awaiting_visible_frame: false, + ever_visible_frame: false, + } + } + + pub(super) fn device_pixel_ratio(&self) -> f32 { + hidpi_scale_milli_to_f32(self.hidpi_scale_milli) + } +} + +pub(super) fn ensure_session<'a>( + host: &mut SoftwareServoHost, + sessions: &'a mut HashMap, + key: String, + tab_id: &TabId, + profile_id: &ProfileId, + width: u32, + height: u32, +) -> Result<&'a mut LiveSession, LiveSidecarError> { + if !sessions.contains_key(&key) { + let webview_id = host.create_webview_with_size( + tab_id.clone(), + profile_id.clone(), + ServoSurfaceSize::new(width, height), + )?; + sessions.insert(key.clone(), LiveSession::new(webview_id, width, height)); + } + + sessions.get_mut(&key).ok_or(LiveSidecarError::SessionUnavailable) +} + +pub(super) fn apply_layout( + host: &mut SoftwareServoHost, + session: &mut LiveSession, + width: u32, + height: u32, + page_zoom_percent: u16, + device_pixel_ratio: f32, +) -> Result { + let mut changed = false; + // Push the device pixel ratio BEFORE resize. Servo's WebView + // defaults hidpi to 1.0; without this the first layout treats + // physical-pixel viewport widths as CSS-pixel widths and the page + // lays out half the size you'd expect on a Retina display. + let hidpi_scale_milli = encode_hidpi_scale_milli(device_pixel_ratio); + if session.hidpi_scale_milli != hidpi_scale_milli { + host.set_hidpi_scale(ely_servo_host::HidpiScaleRequest { + webview_id: session.webview_id.clone(), + scale_factor: hidpi_scale_milli_to_f32(hidpi_scale_milli), + })?; + session.hidpi_scale_milli = hidpi_scale_milli; + changed = true; + } + + if session.width != width || session.height != height { + host.resize(ResizeRequest { webview_id: session.webview_id.clone(), width, height })?; + session.width = width; + session.height = height; + changed = true; + } + + if session.page_zoom_percent != page_zoom_percent { + host.set_page_zoom(PageZoomRequest { + webview_id: session.webview_id.clone(), + zoom_factor: f32::from(page_zoom_percent) / 100.0, + })?; + session.page_zoom_percent = page_zoom_percent; + changed = true; + } + + Ok(changed) +} + +pub(super) fn apply_permissions( + host: &mut SoftwareServoHost, + session: &LiveSession, + profile_id: &ProfileId, + permissions: Vec, +) -> Result<(), LiveSidecarError> { + for permission in permissions { + host.set_permission( + PermissionRequest { + webview_id: session.webview_id.clone(), + profile_id: profile_id.clone(), + origin: ely_domain::SiteOrigin::parse(permission.origin)?, + feature: ely_domain::SitePermissionFeature::parse(permission.feature.as_str())?, + }, + PermissionDecision::from(ely_domain::SitePermissionDecision::parse( + permission.decision.as_str(), + )?), + )?; + } + + Ok(()) +} + +pub(super) fn apply_input( + host: &mut SoftwareServoHost, + session: &mut LiveSession, + input: LiveInput, +) -> Result { + let mut changed = false; + if input.scroll_delta_x != 0 || input.scroll_delta_y != 0 { + let (point_x, point_y) = input.scroll_point()?; + host.scroll(ScrollRequest { + webview_id: session.webview_id.clone(), + delta_x: input.scroll_delta_x, + delta_y: input.scroll_delta_y, + point_x, + point_y, + })?; + session.scroll_x = positive_scroll_component(session.scroll_x, input.scroll_delta_x); + session.scroll_y = positive_scroll_component(session.scroll_y, input.scroll_delta_y); + changed = true; + } + + if let (Some(x), Some(y)) = (input.hover_x, input.hover_y) { + host.hover(MouseHoverRequest { webview_id: session.webview_id.clone(), x, y })?; + changed = true; + } + + if let (Some(x), Some(y)) = (input.click_x, input.click_y) { + host.click(MouseClickRequest { webview_id: session.webview_id.clone(), x, y })?; + changed = true; + } + + if let Some(text) = input.typed_text { + host.type_text(KeyboardTextRequest { webview_id: session.webview_id.clone(), text })?; + changed = true; + } + + Ok(changed) +} + +pub(super) struct LiveInput { + pub(super) scroll_delta_x: i32, + pub(super) scroll_delta_y: i32, + pub(super) scroll_point_x: Option, + pub(super) scroll_point_y: Option, + pub(super) click_x: Option, + pub(super) click_y: Option, + pub(super) hover_x: Option, + pub(super) hover_y: Option, + pub(super) typed_text: Option, +} + +impl LiveInput { + fn scroll_point(&self) -> Result<(u32, u32), LiveSidecarError> { + let point = match (self.scroll_point_x, self.scroll_point_y) { + (Some(x), Some(y)) => (x, y), + _ => return Err(LiveSidecarError::IncompleteScrollPoint), + }; + Ok(point) + } +} + +fn encode_hidpi_scale_milli(scale: f32) -> u32 { + if !scale.is_finite() || scale <= 0.0 { + return 1_000; + } + let scaled = (scale * 1_000.0).round(); + scaled.clamp(500.0, 5_000.0) as u32 +} + +fn hidpi_scale_milli_to_f32(milli: u32) -> f32 { + milli as f32 / 1_000.0 +} + +fn positive_scroll_component(current: i32, delta: i32) -> i32 { + let value = i64::from(current) + i64::from(delta); + value.clamp(0, i64::from(i32::MAX)) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_live_session_forces_first_resize_after_hidpi() { + let session = LiveSession::new(ely_domain::WebViewId::new(), 1280, 720); + + assert_ne!( + session.width, 1280, + "first apply_layout must resize after hidpi has been pushed", + ); + assert_ne!( + session.height, 720, + "first apply_layout must resize after hidpi has been pushed", + ); + } +}