From aa8182bec4d23752ae24c9d6b091c681f41db8d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 21 May 2026 15:44:26 -0400 Subject: [PATCH] update --- crates/ely_app/src/main.rs | 58 +++++++++--- crates/ely_app/src/services/servo_live.rs | 94 ++++++++++--------- .../ely_app/src/services/servo_live_types.rs | 39 +++++++- crates/ely_app/src/shell/web_surface.rs | 12 +-- .../src/shell/web_surface_live_site_tests.rs | 11 ++- .../ely_app/src/shell/web_surface_runtime.rs | 8 +- crates/ely_servo_host/src/host.rs | 38 +++++++- crates/ely_servo_host/src/lib.rs | 2 +- crates/ely_servo_host/src/runtime.rs | 50 +++++++++- crates/ely_servo_host/src/runtime_webview.rs | 37 ++++++-- scripts/verify_render.sh | 30 ++++-- 11 files changed, 282 insertions(+), 97 deletions(-) diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index 44275ca..4cbf5d1 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -20,6 +20,7 @@ use gpui_component_assets::Assets; use shell::ElyShell; use shell::chrome::{TRAFFIC_LIGHT_ORIGIN_X, TRAFFIC_LIGHT_ORIGIN_Y}; use shortcuts::bind_shortcuts; +use url::Url; use crate::brand::{DEEP_LINK_PREFIX, PRODUCT_NAME}; @@ -55,7 +56,7 @@ actions!( fn main() { init_tracing(); let pending_deep_links = PendingDeepLinks::default(); - pending_deep_links.push(startup_deep_links(env::args().skip(1))); + pending_deep_links.push(startup_open_urls(env::args().skip(1))); let open_url_queue = pending_deep_links.clone(); let application = Application::new().with_assets(Assets); @@ -248,7 +249,7 @@ fn open_deep_links( current_window: &Rc>>, cx: &mut App, ) { - let urls = urls.into_iter().filter_map(|url| parse_ely_deep_link(&url)).collect::>(); + let urls = urls.into_iter().filter_map(|url| parse_open_url(&url)).collect::>(); if urls.is_empty() { return; @@ -314,9 +315,19 @@ fn parse_ely_deep_link(value: &str) -> Option { .flatten() } -fn startup_deep_links(args: impl IntoIterator) -> Vec { +fn parse_open_url(value: &str) -> Option { + parse_ely_deep_link(value).or_else(|| parse_external_open_url(value)) +} + +fn parse_external_open_url(value: &str) -> Option { + let parsed = UrlText::from_address_text(value).ok()?; + let url = Url::parse(parsed.as_str()).ok()?; + matches!(url.scheme(), "http" | "https").then(|| UrlText::parse(url.to_string()).ok()).flatten() +} + +fn startup_open_urls(args: impl IntoIterator) -> Vec { args.into_iter() - .filter_map(|arg| parse_ely_deep_link(&arg).map(|url| url.as_str().to_string())) + .filter_map(|arg| parse_open_url(&arg).map(|url| url.as_str().to_string())) .collect() } @@ -346,7 +357,7 @@ fn open_private_window(_: &OpenPrivateWindow, cx: &mut App) { #[cfg(test)] mod tests { - use super::{PendingDeepLinks, parse_ely_deep_link, startup_deep_links}; + use super::{PendingDeepLinks, parse_ely_deep_link, parse_open_url, startup_open_urls}; #[test] fn pending_deep_links_drains_urls_in_order() { @@ -381,16 +392,41 @@ mod tests { } #[test] - fn startup_deep_links_filter_and_normalize_args() { - let links = startup_deep_links( - ["--ignored", " ELY://history ", "https://example.com", "ely://auth/callback?code=abc"] - .into_iter() - .map(str::to_string), + fn parse_open_url_accepts_external_pages() { + let url = parse_open_url(" HTTPS://example.com "); + + assert_eq!(url.as_ref().map(|url| url.as_str()), Some("https://example.com/")); + } + + #[test] + fn parse_open_url_accepts_address_text_domains() { + let url = parse_open_url("servo.org"); + + assert_eq!(url.as_ref().map(|url| url.as_str()), Some("https://servo.org/")); + } + + #[test] + fn startup_open_urls_filter_and_normalize_args() { + let links = startup_open_urls( + [ + "--ignored", + " ELY://history ", + "https://example.com", + "servo.org", + "ely://auth/callback?code=abc", + ] + .into_iter() + .map(str::to_string), ); assert_eq!( links, - vec!["ely://history".to_string(), "ely://auth/callback?code=abc".to_string()] + vec![ + "ely://history".to_string(), + "https://example.com/".to_string(), + "https://servo.org/".to_string(), + "ely://auth/callback?code=abc".to_string(), + ] ); } } diff --git a/crates/ely_app/src/services/servo_live.rs b/crates/ely_app/src/services/servo_live.rs index 5133318..a6f93bd 100644 --- a/crates/ely_app/src/services/servo_live.rs +++ b/crates/ely_app/src/services/servo_live.rs @@ -6,7 +6,7 @@ use ely_domain::{ use ely_servo_host::{ HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, - ScrollRequest, ServoHost, ServoSurfaceSize, SoftwareServoHost, + ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, }; #[path = "servo_live_types.rs"] @@ -54,24 +54,18 @@ impl ServoLiveClient { self.apply_navigation(&request, &webview_id, tab_id, requested_url)?; self.apply_input(&request, &webview_id)?; // Match Servo's `examples/winit_minimal.rs`: spin the event loop on - // the embedder-side hot path, never paint. Painting is reactive in - // Servo — `notify_new_frame_ready` on the delegate flags the - // session, and the next `poll` (gated on `has_pending_frame`) - // performs the single paint + present for that frame. Forcing a - // paint here would clear the surface to the WebRender background - // and present it *before* Servo has composited the navigated - // page, which is what produced the per-redirect white-flash on - // sites that perform a chain of redirects (google.com → / - // → /?zx=…). The `ServoLiveFrame` we return only carries the - // snapshot metadata; the on-screen surface is owned by Servo via - // the native NSView and updated through `poll`. + // the embedder-side hot path, never paint. Servo's public + // rendering contract says `notify_new_frame_ready` is the signal + // for `WebView::paint`; URL/title/load-status callbacks are + // metadata updates and travel through snapshots. Painting here + // would present before Servo has composited the navigated page, + // which produced per-redirect white flashes. self.host.tick(); - if !self.session_uses_native_surface(&request.tab_id) { - return Err(ServoLiveError::NativeSurfaceUnavailable); + if self.session_uses_native_surface(&request.tab_id) { + return self.presented_frame_from_session(&request.tab_id, &webview_id).map(Some); } - let frame = self.frame_from_session(&request.tab_id, &webview_id)?; - Ok(Some(frame)) + Ok(None) } pub fn poll(&mut self, tab_id: String) -> Result, ServoLiveError> { @@ -82,15 +76,22 @@ impl ServoLiveClient { let uses_native_surface = session.native_surface_id.is_some(); self.host.tick(); - if !self.host.snapshot(&webview_id)?.has_pending_frame() { + let snapshot = self.host.snapshot(&webview_id)?; + if !snapshot.has_pending_frame() && !snapshot.has_pending_metadata() { return Ok(None); } - if !uses_native_surface { - return Err(ServoLiveError::NativeSurfaceUnavailable); + if uses_native_surface { + if snapshot.has_pending_frame() { + self.host.paint_without_readback_with_completion(&webview_id, false)?; + } + return self.presented_frame_from_session(&tab_id, &webview_id).map(Some); } - self.host.paint_without_readback_with_completion(&webview_id, false)?; - self.frame_from_session(&tab_id, &webview_id).map(Some) + + if snapshot.has_pending_frame() { + self.host.paint(&webview_id)?; + } + self.rendered_frame_from_session(&tab_id, &webview_id) } pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> { @@ -252,8 +253,7 @@ impl ServoLiveClient { // `set_history`) is the source of truth — if it already // matches the requested URL, this URL change came *from* // Servo and only needs an embedder-side bookkeeping sync. - let servo_current_url = - self.host.snapshot(webview_id)?.url().map(str::to_string); + let servo_current_url = self.host.snapshot(webview_id)?.url().map(str::to_string); if servo_current_url.as_deref() == Some(requested_url.as_str()) { if let Some(session) = self.sessions.get_mut(&request.tab_id) { session.requested_url = Some(requested_url.as_str().to_string()); @@ -314,7 +314,7 @@ impl ServoLiveClient { Ok(()) } - fn frame_from_session( + fn presented_frame_from_session( &self, tab_id: &str, webview_id: &WebViewId, @@ -325,7 +325,7 @@ impl ServoLiveClient { })); }; if session.native_surface_id.is_some() { - let snapshot = self.host.snapshot(webview_id)?; + let snapshot = self.host.snapshot_and_mark_metadata_observed(webview_id)?; return Ok(ServoLiveFrame::from_presented( snapshot, session.width, @@ -336,6 +336,29 @@ impl ServoLiveClient { Err(ServoLiveError::NativeSurfaceUnavailable) } + fn rendered_frame_from_session( + &self, + tab_id: &str, + webview_id: &WebViewId, + ) -> Result, ServoLiveError> { + let Some(session) = self.sessions.get(tab_id) else { + return Err(ServoLiveError::Host(ServoHostError::WebViewNotFound { + id: webview_id.clone(), + })); + }; + let rendered_frame = match self.host.last_rendered_frame() { + Ok(frame) => frame, + Err(ServoHostError::RenderedFrameUnavailable) => return Ok(None), + Err(error) => return Err(ServoLiveError::Host(error)), + }; + let snapshot = self.host.snapshot_and_mark_metadata_observed(webview_id)?; + Ok(Some(ServoLiveFrame::from_rendered( + snapshot, + rendered_frame, + session.device_pixel_ratio, + ))) + } + fn session_uses_native_surface(&self, tab_id: &str) -> bool { self.sessions.get(tab_id).is_some_and(|session| session.native_surface_id.is_some()) } @@ -425,12 +448,7 @@ mod tests { // physical dimensions, so only the hidpi push should fire. let change = ViewportChange::between( &request(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT), - &session( - 2880, - 1800, - SERVO_DEFAULT_DEVICE_PIXEL_RATIO, - SERVO_DEFAULT_PAGE_ZOOM_PERCENT, - ), + &session(2880, 1800, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT), ); assert_eq!( change, @@ -443,18 +461,8 @@ mod tests { // On a 1.0-DPR display the fresh session already matches the // request — Servo's defaults are exactly what we asked for. let change = ViewportChange::between( - &request( - 1280, - 720, - SERVO_DEFAULT_DEVICE_PIXEL_RATIO, - SERVO_DEFAULT_PAGE_ZOOM_PERCENT, - ), - &session( - 1280, - 720, - SERVO_DEFAULT_DEVICE_PIXEL_RATIO, - SERVO_DEFAULT_PAGE_ZOOM_PERCENT, - ), + &request(1280, 720, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT), + &session(1280, 720, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT), ); assert_eq!(change, ViewportChange::default()); } diff --git a/crates/ely_app/src/services/servo_live_types.rs b/crates/ely_app/src/services/servo_live_types.rs index 145c164..9280bb2 100644 --- a/crates/ely_app/src/services/servo_live_types.rs +++ b/crates/ely_app/src/services/servo_live_types.rs @@ -1,5 +1,5 @@ use ely_domain::SitePermissionDecision; -use ely_servo_host::{ServoHostError, WebViewSnapshot, WebViewState}; +use ely_servo_host::{RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState}; use gpui::NativeSurfaceHandle; use serde::Serialize; use thiserror::Error; @@ -90,6 +90,34 @@ impl ServoLiveFrame { } } + pub(super) fn from_rendered( + snapshot: WebViewSnapshot, + rendered_frame: RenderedFrame, + device_pixel_ratio: f32, + ) -> Self { + let width = rendered_frame.width(); + let height = rendered_frame.height(); + let (css_viewport_width, css_viewport_height) = + css_viewport_size(width, height, device_pixel_ratio); + Self { + loaded_url: snapshot.url().map(str::to_string), + title: snapshot.title().map(str::to_string), + render_state: render_state_label(snapshot.state()).to_string(), + width, + height, + device_pixel_ratio, + css_viewport_width, + css_viewport_height, + #[cfg(all(test, feature = "live-site-smoke"))] + non_white_pixel_count: rendered_frame.non_white_pixel_count(), + #[cfg(all(test, feature = "live-site-smoke"))] + content_pixel_count: rendered_frame.content_pixel_count(), + #[cfg(all(test, feature = "live-site-smoke"))] + sample_hash: rendered_frame.sample_hash(), + rgba_bytes: Some(rendered_frame.rgba_bytes().to_vec()), + } + } + #[must_use] pub fn loaded_url(&self) -> Option<&str> { self.loaded_url.as_deref() @@ -155,6 +183,9 @@ impl ServoLiveFrame { #[cfg(test)] pub(crate) fn for_test(width: u32, height: u32, rgba_bytes: Vec) -> Self { + #[cfg(all(test, feature = "live-site-smoke"))] + let summary = + ely_servo_host::RenderedFrameSummary::from_rgba_bytes(width, height, &rgba_bytes); Self { loaded_url: Some("https://example.com/".to_string()), title: Some("Example".to_string()), @@ -165,11 +196,11 @@ impl ServoLiveFrame { css_viewport_width: width, css_viewport_height: height, #[cfg(all(test, feature = "live-site-smoke"))] - non_white_pixel_count: 0, + non_white_pixel_count: summary.non_white_pixel_count(), #[cfg(all(test, feature = "live-site-smoke"))] - content_pixel_count: 0, + content_pixel_count: summary.content_pixel_count(), #[cfg(all(test, feature = "live-site-smoke"))] - sample_hash: 0, + sample_hash: summary.sample_hash(), rgba_bytes: Some(rgba_bytes), } } diff --git a/crates/ely_app/src/shell/web_surface.rs b/crates/ely_app/src/shell/web_surface.rs index 5ec5ec9..76324d5 100644 --- a/crates/ely_app/src/shell/web_surface.rs +++ b/crates/ely_app/src/shell/web_surface.rs @@ -79,10 +79,8 @@ impl WebSurfaceStore { // which destroys and reallocates the framebuffer — the source of // the per-frame blank flash. The first ensure (when no prior // `last_ensure_key` is set) always fires so the page can load. - let already_ensured = self - .surfaces - .get(tab.id()) - .is_some_and(|surface| surface.last_ensure_key.is_some()); + let already_ensured = + self.surfaces.get(tab.id()).is_some_and(|surface| surface.last_ensure_key.is_some()); if already_ensured && self .surfaces @@ -211,9 +209,9 @@ impl WebSurfaceStore { // one frame of the gesture settling. Without this boost the // idle 80 ms polling adds a noticeable lag between letting go // of a resize and the page re-laying-out at the final size. - let any_settling = visible_tab_ids - .iter() - .any(|tab_id| self.surfaces.get(tab_id).is_some_and(|s| s.viewport_size_is_settling(now))); + let any_settling = visible_tab_ids.iter().any(|tab_id| { + self.surfaces.get(tab_id).is_some_and(|s| s.viewport_size_is_settling(now)) + }); if any_settling { return ACTIVE_POLL_INTERVAL; } diff --git a/crates/ely_app/src/shell/web_surface_live_site_tests.rs b/crates/ely_app/src/shell/web_surface_live_site_tests.rs index 516eebe..95d3dc6 100644 --- a/crates/ely_app/src/shell/web_surface_live_site_tests.rs +++ b/crates/ely_app/src/shell/web_surface_live_site_tests.rs @@ -201,7 +201,7 @@ fn assert_web_surface_resizes_prd_site() -> Result<(), Box> { store.ensure_surface(&tab, ProfileDataMode::Transient, &[]); let _ = wait_for_ready_frame_at_size( &mut store, - tab.id(), + &tab, case, LIVE_SURFACE_WIDTH, LIVE_SURFACE_HEIGHT, @@ -216,7 +216,7 @@ fn assert_web_surface_resizes_prd_site() -> Result<(), Box> { store.ensure_surface(&tab, ProfileDataMode::Transient, &[]); wait_for_ready_frame_at_size( &mut store, - tab.id(), + &tab, case, RESIZED_LIVE_SURFACE_WIDTH, RESIZED_LIVE_SURFACE_HEIGHT, @@ -377,7 +377,7 @@ fn wait_for_ready_frame_at_scroll( fn wait_for_ready_frame_at_size( store: &mut WebSurfaceStore, - tab_id: &TabId, + tab: &BrowserTab, case: &LiveSiteCase, expected_width: u32, expected_height: u32, @@ -386,8 +386,9 @@ fn wait_for_ready_frame_at_size( let mut last_error = None; loop { - store.tick(std::slice::from_ref(tab_id)); - match store.state(tab_id) { + store.ensure_surface(tab, ProfileDataMode::Transient, &[]); + store.tick(std::slice::from_ref(tab.id())); + match store.state(tab.id()) { Some(WebSurfaceState::Ready(frame)) if frame.size().width == expected_width && frame.size().height == expected_height => diff --git a/crates/ely_app/src/shell/web_surface_runtime.rs b/crates/ely_app/src/shell/web_surface_runtime.rs index 0f69aec..fc2acf9 100644 --- a/crates/ely_app/src/shell/web_surface_runtime.rs +++ b/crates/ely_app/src/shell/web_surface_runtime.rs @@ -395,7 +395,9 @@ impl WebSurfaceRuntime { let Some(scoped) = self.worker.take() else { return; }; - if let Some(path) = scoped.transient_profile_data_dir { + let ScopedWorker { worker, transient_profile_data_dir } = scoped; + drop(worker); + if let Some(path) = transient_profile_data_dir { let _ = fs::remove_dir_all(path); } } @@ -404,7 +406,9 @@ impl WebSurfaceRuntime { let Some(scoped) = self.direct_client.take() else { return; }; - if let Some(path) = scoped.transient_profile_data_dir { + let ScopedDirectClient { client, transient_profile_data_dir } = scoped; + drop(client); + if let Some(path) = transient_profile_data_dir { let _ = fs::remove_dir_all(path); } } diff --git a/crates/ely_servo_host/src/host.rs b/crates/ely_servo_host/src/host.rs index 11442c8..330f85e 100644 --- a/crates/ely_servo_host/src/host.rs +++ b/crates/ely_servo_host/src/host.rs @@ -147,6 +147,29 @@ impl RenderedFrame { } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WebViewSnapshotPending { + has_pending_frame: bool, + has_pending_metadata: bool, +} + +impl WebViewSnapshotPending { + #[must_use] + pub fn new(has_pending_frame: bool, has_pending_metadata: bool) -> Self { + Self { has_pending_frame, has_pending_metadata } + } + + #[must_use] + pub fn has_pending_frame(&self) -> bool { + self.has_pending_frame + } + + #[must_use] + pub fn has_pending_metadata(&self) -> bool { + self.has_pending_metadata + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct WebViewSnapshot { webview_id: WebViewId, @@ -155,7 +178,7 @@ pub struct WebViewSnapshot { state: WebViewState, url: Option, title: Option, - has_pending_frame: bool, + pending: WebViewSnapshotPending, } impl WebViewSnapshot { @@ -167,9 +190,9 @@ impl WebViewSnapshot { state: WebViewState, url: Option, title: Option, - has_pending_frame: bool, + pending: WebViewSnapshotPending, ) -> Self { - Self { webview_id, tab_id, profile_id, state, url, title, has_pending_frame } + Self { webview_id, tab_id, profile_id, state, url, title, pending } } #[must_use] @@ -204,7 +227,14 @@ impl WebViewSnapshot { #[must_use] pub fn has_pending_frame(&self) -> bool { - self.has_pending_frame + self.pending.has_pending_frame() + } + + /// Returns true when URL, title, or load-state changed since the + /// last embedder snapshot observation. + #[must_use] + pub fn has_pending_metadata(&self) -> bool { + self.pending.has_pending_metadata() } } diff --git a/crates/ely_servo_host/src/lib.rs b/crates/ely_servo_host/src/lib.rs index da1b0fc..2c40fa5 100644 --- a/crates/ely_servo_host/src/lib.rs +++ b/crates/ely_servo_host/src/lib.rs @@ -18,7 +18,7 @@ pub use host::{ HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest, NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame, RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest, - WebViewSnapshot, WebViewState, + WebViewSnapshot, WebViewSnapshotPending, WebViewState, }; #[cfg(feature = "servo-engine")] pub use runtime::{RenderingContextKind, ServoSurfaceSize, SoftwareServoHost}; diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index fa012a8..234ba31 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -45,6 +45,9 @@ pub struct SoftwareServoHost { default_surface_size: ServoSurfaceSize, rendering_context_kind: RenderingContextKind, webviews: HashMap, + // Servo 0.1.0 can still run script tasks after a remote page moves away. + // Retain hidden about:blank WebViews until host shutdown releases them together. + retired_webviews: Vec, permissions: PermissionStore, wake_requested: Arc, last_rendered_frame: Option, @@ -120,7 +123,27 @@ impl SoftwareServoHost { } pub fn close_webview(&mut self, webview_id: &WebViewId) -> bool { - self.webviews.remove(webview_id).is_some() + let Some(webview) = self.webviews.remove(webview_id) else { + return false; + }; + self.retire_webview(webview); + true + } + + fn retire_webview(&mut self, webview: HostWebView) { + webview.webview.hide(); + if let Ok(blank_url) = Url::parse("about:blank") { + webview.webview.load(blank_url); + } + for _ in 0..32 { + self.servo.spin_event_loop(); + if webview.current_url().as_deref() == Some("about:blank") + && matches!(webview.state(), WebViewState::Complete) + { + break; + } + } + self.retired_webviews.push(webview); } fn new_started( @@ -143,6 +166,7 @@ impl SoftwareServoHost { default_surface_size: size, rendering_context_kind, webviews: HashMap::new(), + retired_webviews: Vec::new(), permissions: Rc::new(RefCell::new(HashMap::new())), wake_requested, last_rendered_frame: None, @@ -190,6 +214,24 @@ impl SoftwareServoHost { self.last_rendered_frame = Some(rendered_frame); Ok(()) } + + /// Returns the current snapshot and acknowledges metadata-only + /// updates without clearing Servo's frame-ready signal. + pub fn snapshot_and_mark_metadata_observed( + &self, + webview_id: &WebViewId, + ) -> Result { + let webview = self.webview(webview_id)?; + let snapshot = webview.snapshot(webview_id); + webview.delegate.mark_metadata_observed(); + Ok(snapshot) + } + + fn drain_after_webview_close(&self) { + for _ in 0..16 { + self.servo.spin_event_loop(); + } + } } fn install_rustls_provider() { @@ -395,6 +437,12 @@ impl ServoHost for SoftwareServoHost { impl Drop for SoftwareServoHost { fn drop(&mut self) { + let webviews = std::mem::take(&mut self.webviews); + for webview in webviews.into_values() { + self.retire_webview(webview); + } + self.retired_webviews.clear(); + self.drain_after_webview_close(); SERVO_RUNTIME_STARTED.store(false, Ordering::Release); } } diff --git a/crates/ely_servo_host/src/runtime_webview.rs b/crates/ely_servo_host/src/runtime_webview.rs index 808cb11..5196e62 100644 --- a/crates/ely_servo_host/src/runtime_webview.rs +++ b/crates/ely_servo_host/src/runtime_webview.rs @@ -5,7 +5,7 @@ use servo::{LoadStatus, RenderingContext, WebView, WebViewDelegate}; use url::Url; use crate::{ - PermissionDecision, WebViewSnapshot, WebViewState, + PermissionDecision, WebViewSnapshot, WebViewSnapshotPending, WebViewState, runtime_permissions::{PermissionStore, permission_decision_for_webview}, }; @@ -27,7 +27,10 @@ impl HostWebView { self.state(), self.current_url(), self.current_title(), - self.delegate.has_pending_frame(), + WebViewSnapshotPending::new( + self.delegate.has_pending_frame(), + self.delegate.has_pending_metadata(), + ), ) } @@ -62,6 +65,7 @@ pub(super) struct HostWebViewDelegate { url: RefCell>, title: RefCell>, has_pending_frame: Cell, + has_pending_metadata: Cell, } impl HostWebViewDelegate { @@ -73,6 +77,7 @@ impl HostWebViewDelegate { url: RefCell::new(None), title: RefCell::new(None), has_pending_frame: Cell::new(false), + has_pending_metadata: Cell::new(false), } } @@ -94,12 +99,12 @@ impl HostWebViewDelegate { fn record_url_change(&self, url: String) { self.url.replace(Some(url)); - self.has_pending_frame.set(true); + self.has_pending_metadata.set(true); } fn record_title_change(&self, title: Option) { self.title.replace(title); - self.has_pending_frame.set(true); + self.has_pending_metadata.set(true); } fn record_load_status(&self, status: LoadStatus) { @@ -108,16 +113,24 @@ impl HostWebViewDelegate { LoadStatus::Complete => WebViewState::Complete, }; self.set_state(state); - self.has_pending_frame.set(true); + self.has_pending_metadata.set(true); } pub(super) fn has_pending_frame(&self) -> bool { self.has_pending_frame.get() } + pub(super) fn has_pending_metadata(&self) -> bool { + self.has_pending_metadata.get() + } + pub(super) fn mark_frame_presented(&self) { self.has_pending_frame.set(false); } + + pub(super) fn mark_metadata_observed(&self) { + self.has_pending_metadata.set(false); + } } impl WebViewDelegate for HostWebViewDelegate { @@ -172,21 +185,25 @@ mod tests { use super::HostWebViewDelegate; #[test] - fn metadata_changes_mark_pending_frame() { + fn metadata_changes_are_separate_from_pending_frame() { let delegate = HostWebViewDelegate::new(ProfileId::new(), Rc::new(RefCell::new(HashMap::new()))); assert!(!delegate.has_pending_frame()); + assert!(!delegate.has_pending_metadata()); delegate.record_title_change(Some("Example Domain".to_string())); assert_eq!(delegate.title().as_deref(), Some("Example Domain")); - assert!(delegate.has_pending_frame()); - - delegate.mark_frame_presented(); assert!(!delegate.has_pending_frame()); + assert!(delegate.has_pending_metadata()); + + delegate.mark_metadata_observed(); + assert!(!delegate.has_pending_frame()); + assert!(!delegate.has_pending_metadata()); delegate.record_url_change("https://example.com/".to_string()); assert_eq!(delegate.url().as_deref(), Some("https://example.com/")); - assert!(delegate.has_pending_frame()); + assert!(!delegate.has_pending_frame()); + assert!(delegate.has_pending_metadata()); } } diff --git a/scripts/verify_render.sh b/scripts/verify_render.sh index f0cf037..6cf97f9 100755 --- a/scripts/verify_render.sh +++ b/scripts/verify_render.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # T16 — real-window screencapture sanity check. # -# Boots ./target/release/ely_app in the background, lets it open about:blank, -# grabs a screencapture, then asserts the PNG is non-trivial (size + dimensions -# + non-white center). Stderr from the app is tee'd to a log so a crash leaves -# evidence behind. +# Boots ./target/release/ely_app in the background, opens a live page through +# Servo's native surface path, grabs a screencapture, then asserts the PNG is +# non-trivial (size + dimensions + non-white center). Stderr from the app is +# tee'd to a log so a crash leaves evidence behind. # # Idempotent: kills any leftover ely_app processes from prior runs before # starting, and cleans up its own background process on exit (success or fail). @@ -18,6 +18,7 @@ TIMESTAMP="$(date +%Y%m%d-%H%M%S)" SHOT_PATH="/tmp/ely-verify-${TIMESTAMP}.png" STDERR_LOG="/tmp/ely-verify-stderr.log" APP_BIN="${REPO_ROOT}/target/release/ely_app" +SMOKE_URL="${ELY_VERIFY_URL:-https://servo.org/}" APP_PID="" cleanup() { @@ -32,6 +33,7 @@ cleanup() { fi # Stragglers from prior runs / child processes pkill -f "target/release/ely_app" 2>/dev/null || true + pkill -x "ely_app" 2>/dev/null || true } trap cleanup EXIT INT TERM @@ -47,6 +49,7 @@ fail() { echo "[1/6] killing stale ely_app processes" pkill -f "target/release/ely_app" 2>/dev/null || true +pkill -x "ely_app" 2>/dev/null || true sleep 0.5 echo "[2/6] cargo build --release -p ely_app" @@ -55,21 +58,30 @@ if ! cargo build --release -p ely_app; then fi [[ -x "${APP_BIN}" ]] || fail "binary missing: ${APP_BIN}" -echo "[3/6] launching ${APP_BIN} (stderr -> ${STDERR_LOG})" +echo "[3/6] launching ${APP_BIN} ${SMOKE_URL} (stderr -> ${STDERR_LOG})" : > "${STDERR_LOG}" -"${APP_BIN}" >/dev/null 2>"${STDERR_LOG}" & +"${APP_BIN}" "${SMOKE_URL}" >/dev/null 2>"${STDERR_LOG}" & APP_PID=$! echo " pid=${APP_PID}" -echo "[4/6] waiting 8s for window + about:blank to settle" -for i in 1 2 3 4 5 6 7 8; do +echo "[4/6] waiting 12s for window + live page to settle" +for i in 1 2 3 4 5 6 7 8 9 10 11 12; do sleep 1 if ! kill -0 "${APP_PID}" 2>/dev/null; then fail "app exited early during warm-up (after ${i}s)" fi done -echo "[5/6] screencapture -> ${SHOT_PATH}" +echo "[5/6] activating pid ${APP_PID} and screencapture -> ${SHOT_PATH}" +if ! osascript >/dev/null < here # because we don't have a stable Cocoa window id; the app paints into the # primary display and that's what we care about.