feat(servo): isolate profiles with hardware sidecars
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
#![cfg(feature = "hardware-render")]
|
||||
|
||||
use dpi::PhysicalSize;
|
||||
use ely_servo_host::{HardwareOffscreenContext, IOSurfaceHandle, IOSurfaceIdentity};
|
||||
use servo::RenderingContext;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const HARDWARE_HOST_CHILD_ENV: &str = "ELY_SERVO_HARDWARE_HOST_CHILD";
|
||||
|
||||
#[test]
|
||||
fn identity_uses_handle_dimensions() {
|
||||
let handle = IOSurfaceHandle { mach_port_name: 7, surface_id: 11, width: 640, height: 480 };
|
||||
|
||||
assert_eq!(
|
||||
IOSurfaceIdentity::from_handle(handle),
|
||||
IOSurfaceIdentity { surface_id: 11, width: 640, height: 480 }
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn hardware_context_constructs() -> Result<(), String> {
|
||||
let context = HardwareOffscreenContext::new(PhysicalSize::new(64, 64))
|
||||
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
|
||||
assert_eq!(context.size(), PhysicalSize::new(64, 64));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn presented_surface_exposes_iosurface_identity_and_mach_port() -> Result<(), String> {
|
||||
let size = PhysicalSize::new(256, 192);
|
||||
let context = HardwareOffscreenContext::new(size)
|
||||
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
|
||||
|
||||
assert_eq!(
|
||||
context
|
||||
.peek_iosurface_identity()
|
||||
.map_err(|error| format!("identity probe failed: {error:?}"))?,
|
||||
None
|
||||
);
|
||||
context.make_current().map_err(|error| format!("make current failed: {error:?}"))?;
|
||||
context.prepare_for_rendering();
|
||||
context.present();
|
||||
|
||||
let identity = context
|
||||
.peek_iosurface_identity()
|
||||
.map_err(|error| format!("identity probe failed: {error:?}"))?
|
||||
.ok_or_else(|| "present did not expose an IOSurface".to_string())?;
|
||||
let handle = context
|
||||
.current_iosurface_mach_port()
|
||||
.map_err(|error| format!("Mach port creation failed: {error:?}"))?;
|
||||
|
||||
assert_ne!(handle.mach_port_name, 0);
|
||||
assert_eq!(identity, IOSurfaceIdentity::from_handle(handle));
|
||||
assert_eq!(identity.width, size.width);
|
||||
assert_eq!(identity.height, size.height);
|
||||
assert_eq!(deallocate_mach_port(handle.mach_port_name), mach2::kern_return::KERN_SUCCESS);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn hardware_host_paints_and_presents_an_iosurface() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::env;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
if env::var_os(HARDWARE_HOST_CHILD_ENV).is_some() {
|
||||
return exercise_hardware_host();
|
||||
}
|
||||
|
||||
let output = Command::new(env::current_exe()?)
|
||||
.arg("--exact")
|
||||
.arg("hardware_host_paints_and_presents_an_iosurface")
|
||||
.env(HARDWARE_HOST_CHILD_ENV, "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"hardware host child failed\nstatus: {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn exercise_hardware_host() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use ely_domain::{ProfileId, TabId, UrlText};
|
||||
use ely_servo_host::{
|
||||
NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize, SoftwareServoHost,
|
||||
};
|
||||
|
||||
let size = ServoSurfaceSize::new(320, 240);
|
||||
let mut host = SoftwareServoHost::new_with_config_dir_and_kind(
|
||||
size,
|
||||
None,
|
||||
RenderingContextKind::Hardware,
|
||||
)?;
|
||||
let tab_id = TabId::new();
|
||||
let webview_id = host.create_webview(tab_id.clone(), ProfileId::new())?;
|
||||
host.navigate(NavigationRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
tab_id,
|
||||
url: UrlText::parse("data:text/html,%3Cbody%20style%3D%27background%3A%230369a1%27%3E")?,
|
||||
})?;
|
||||
|
||||
for _ in 0..5_000 {
|
||||
host.tick();
|
||||
if host.snapshot(&webview_id)?.has_pending_frame() {
|
||||
host.paint_without_readback(&webview_id)?;
|
||||
if host.peek_iosurface_identity(&webview_id)?.is_some() {
|
||||
let handle = host
|
||||
.current_iosurface_handle(&webview_id)?
|
||||
.ok_or("hardware webview did not expose an IOSurface handle")?;
|
||||
assert_eq!(handle.width, 320);
|
||||
assert_eq!(handle.height, 240);
|
||||
assert_eq!(
|
||||
deallocate_mach_port(handle.mach_port_name),
|
||||
mach2::kern_return::KERN_SUCCESS
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
|
||||
Err("timed out waiting for a hardware IOSurface frame".into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[expect(unsafe_code)]
|
||||
fn deallocate_mach_port(name: u32) -> i32 {
|
||||
// `name` is a live send right minted by IOSurfaceCreateMachPort in this task.
|
||||
unsafe { mach2::mach_port::mach_port_deallocate(mach2::traps::mach_task_self(), name) }
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
#![cfg(feature = "servo-engine")]
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
io, thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
use serde_json::json;
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[path = "sidecar/mach_receiver.rs"]
|
||||
mod mach_receiver;
|
||||
#[path = "sidecar/support.rs"]
|
||||
mod support;
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
use mach_receiver::{MachSurfaceReceiver, verify_iosurface};
|
||||
use support::{
|
||||
HEIGHT, LIVE_PROTOCOL_VERSION, MAX_FRAME_DIMENSION, RESPONSE_TIMEOUT, Sidecar, TestDirectory,
|
||||
TestServer, WIDTH, ensure_request,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn live_sidecar_streams_rgba_and_flushes_profile_storage_on_shutdown() -> Result<(), Box<dyn Error>>
|
||||
{
|
||||
let server = TestServer::start()?;
|
||||
let root = TestDirectory::new()?;
|
||||
let persisted_dir = root.path().join("persisted");
|
||||
let fresh_dir = root.path().join("fresh");
|
||||
let profile_id = ProfileId::new();
|
||||
|
||||
let mut writer = Sidecar::spawn(&persisted_dir)?;
|
||||
let stored = writer
|
||||
.ensure_and_wait_visible(&profile_id, &server.url("/set"), "stored-cookie-yes-storage-yes")
|
||||
.map_err(|error| io::Error::other(format!("{error}; server={}", server.diagnostics())))?;
|
||||
assert_eq!(stored.width, WIDTH);
|
||||
assert_eq!(stored.height, HEIGHT);
|
||||
assert!(stored.non_white_pixel_count > 0);
|
||||
assert!(stored.content_pixel_count > 0);
|
||||
assert_ne!(stored.sample_hash, 0);
|
||||
writer.shutdown()?;
|
||||
|
||||
assert!(persisted_dir.join("cookie_jar.json").is_file());
|
||||
assert!(persisted_dir.join("localstorage.json").is_file());
|
||||
assert!(persisted_dir.join("webstorage").is_dir());
|
||||
|
||||
let mut reader = Sidecar::spawn(&persisted_dir)?;
|
||||
reader.ensure_and_wait_visible(
|
||||
&profile_id,
|
||||
&server.url("/read"),
|
||||
"read-cookie-yes-storage-yes",
|
||||
)?;
|
||||
reader.shutdown()?;
|
||||
|
||||
let mut fresh = Sidecar::spawn(&fresh_dir)?;
|
||||
fresh.ensure_and_wait_visible(
|
||||
&profile_id,
|
||||
&server.url("/read"),
|
||||
"read-cookie-no-storage-no",
|
||||
)?;
|
||||
fresh.shutdown()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn servo_originated_history_url_does_not_trigger_a_second_navigation() -> Result<(), Box<dyn Error>>
|
||||
{
|
||||
let server = TestServer::start()?;
|
||||
let root = TestDirectory::new()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let tab_id = TabId::new();
|
||||
let initial_url = server.url("/history");
|
||||
let history_url = server.url("/history?state=1");
|
||||
let mut sidecar = Sidecar::spawn(root.path())?;
|
||||
|
||||
let ensure = ensure_request(&tab_id, &profile_id, &initial_url);
|
||||
let mut response = sidecar.exchange(&ensure)?;
|
||||
let started_at = Instant::now();
|
||||
loop {
|
||||
if let Some(error) = response.error {
|
||||
return Err(io::Error::other(format!("sidecar response error: {error}")).into());
|
||||
}
|
||||
if response.frame.as_ref().is_some_and(|frame| {
|
||||
frame.loaded_url.as_deref() == Some(history_url.as_str())
|
||||
&& frame.title.as_deref() == Some("history-ready")
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
if started_at.elapsed() >= RESPONSE_TIMEOUT {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
format!("timed out waiting for history URL {history_url}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
response = sidecar.exchange(&json!({ "type": "poll", "tab_id": tab_id.as_str() }))?;
|
||||
}
|
||||
|
||||
sidecar.exchange(&ensure_request(&tab_id, &profile_id, &history_url))?;
|
||||
for _ in 0..20 {
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
sidecar.exchange(&json!({ "type": "poll", "tab_id": tab_id.as_str() }))?;
|
||||
}
|
||||
|
||||
assert_eq!(server.request_count("/history"), 1, "{}", server.diagnostics());
|
||||
sidecar.shutdown()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_sidecar_delivers_a_valid_white_page() -> Result<(), Box<dyn Error>> {
|
||||
let server = TestServer::start()?;
|
||||
let root = TestDirectory::new()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let mut sidecar = Sidecar::spawn(root.path())?;
|
||||
|
||||
let frame = sidecar.ensure_and_wait(&profile_id, &server.url("/white"), "white-ready")?;
|
||||
|
||||
assert_eq!(frame.non_white_pixel_count, 0);
|
||||
assert_eq!(frame.content_pixel_count, 0);
|
||||
sidecar.shutdown()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_sidecar_rejects_an_incompatible_protocol() -> Result<(), Box<dyn Error>> {
|
||||
let root = TestDirectory::new()?;
|
||||
let mut sidecar = Sidecar::spawn_without_handshake(root.path())?;
|
||||
|
||||
let response = sidecar.exchange(&json!({
|
||||
"type": "handshake",
|
||||
"protocol_version": LIVE_PROTOCOL_VERSION + 1,
|
||||
}))?;
|
||||
|
||||
assert_eq!(response.protocol_version, Some(LIVE_PROTOCOL_VERSION));
|
||||
assert!(response.error.as_deref().is_some_and(|error| error.contains("protocol mismatch")));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_sidecar_rejects_oversized_frame_dimensions() -> Result<(), Box<dyn Error>> {
|
||||
let root = TestDirectory::new()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let tab_id = TabId::new();
|
||||
let mut sidecar = Sidecar::spawn(root.path())?;
|
||||
let mut ensure = ensure_request(&tab_id, &profile_id, "about:blank");
|
||||
ensure["width"] = json!(MAX_FRAME_DIMENSION + 1);
|
||||
|
||||
let response = sidecar.exchange(&ensure)?;
|
||||
|
||||
assert!(response.error.as_deref().is_some_and(|error| error.contains("dimension limit")));
|
||||
sidecar.shutdown()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[test]
|
||||
fn hardware_sidecar_transfers_a_real_iosurface_mach_descriptor() -> Result<(), Box<dyn Error>> {
|
||||
let receiver = MachSurfaceReceiver::new()?;
|
||||
let server = TestServer::start()?;
|
||||
let root = TestDirectory::new()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let tab_id = TabId::new();
|
||||
let mut sidecar = Sidecar::spawn_hardware(root.path(), receiver.service_name())?;
|
||||
let mut response =
|
||||
sidecar.exchange(&ensure_request(&tab_id, &profile_id, &server.url("/white")))?;
|
||||
let started_at = Instant::now();
|
||||
|
||||
let imported_surface_id = loop {
|
||||
if let Some(error) = response.error {
|
||||
return Err(io::Error::other(format!("hardware sidecar error: {error}")).into());
|
||||
}
|
||||
if let (Some(frame), Some(handle), Some(current_surface_id)) =
|
||||
(response.frame.as_ref(), response.surface_handle, response.current_surface_id)
|
||||
{
|
||||
assert_eq!(frame.rgba_byte_count, 0);
|
||||
assert_eq!(current_surface_id, handle.surface_id);
|
||||
assert_eq!((frame.width, frame.height), (handle.width, handle.height));
|
||||
assert_ne!(handle.mach_port_name, 0);
|
||||
let received_port = receiver.receive(handle.surface_id, Duration::from_secs(2))?;
|
||||
verify_iosurface(received_port, handle.width, handle.height)?;
|
||||
break handle.surface_id;
|
||||
}
|
||||
if started_at.elapsed() >= RESPONSE_TIMEOUT {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"timed out waiting for a hardware IOSurface frame",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
response = sidecar.exchange(&json!({
|
||||
"type": "poll",
|
||||
"tab_id": tab_id.as_str(),
|
||||
"ready_surface_ids": [],
|
||||
"pending_surface_ids": [],
|
||||
}))?;
|
||||
};
|
||||
|
||||
let replay = sidecar.exchange(&json!({
|
||||
"type": "poll",
|
||||
"tab_id": tab_id.as_str(),
|
||||
"ready_surface_ids": [imported_surface_id],
|
||||
"pending_surface_ids": [],
|
||||
}))?;
|
||||
assert_eq!(replay.current_surface_id, Some(imported_surface_id));
|
||||
assert!(replay.surface_handle.is_none());
|
||||
assert_eq!(replay.frame.as_ref().map(|frame| frame.rgba_byte_count), Some(0));
|
||||
|
||||
let republished = sidecar.exchange(&json!({
|
||||
"type": "poll",
|
||||
"tab_id": tab_id.as_str(),
|
||||
"ready_surface_ids": [],
|
||||
"pending_surface_ids": [],
|
||||
}))?;
|
||||
let republished_handle = republished
|
||||
.surface_handle
|
||||
.ok_or_else(|| io::Error::other("evicted IOSurface was not republished"))?;
|
||||
assert_eq!(republished.current_surface_id, Some(imported_surface_id));
|
||||
assert_eq!(republished_handle.surface_id, imported_surface_id);
|
||||
let republished_port = receiver.receive(imported_surface_id, Duration::from_secs(2))?;
|
||||
verify_iosurface(republished_port, republished_handle.width, republished_handle.height)?;
|
||||
|
||||
let pending = sidecar.exchange(&json!({
|
||||
"type": "poll",
|
||||
"tab_id": tab_id.as_str(),
|
||||
"ready_surface_ids": [],
|
||||
"pending_surface_ids": [imported_surface_id],
|
||||
}))?;
|
||||
assert!(pending.frame.is_none());
|
||||
assert!(pending.surface_handle.is_none());
|
||||
|
||||
let reimported = sidecar.exchange(&json!({
|
||||
"type": "poll",
|
||||
"tab_id": tab_id.as_str(),
|
||||
"ready_surface_ids": [imported_surface_id],
|
||||
"pending_surface_ids": [],
|
||||
}))?;
|
||||
assert_eq!(reimported.current_surface_id, Some(imported_surface_id));
|
||||
assert!(reimported.surface_handle.is_none());
|
||||
assert_eq!(reimported.frame.as_ref().map(|frame| frame.rgba_byte_count), Some(0));
|
||||
|
||||
sidecar.shutdown()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use std::{error::Error, ffi::CString, io, mem, time::Duration};
|
||||
|
||||
use mach2::{
|
||||
bootstrap::{bootstrap_port, bootstrap_register},
|
||||
kern_return::KERN_SUCCESS,
|
||||
mach_port::{
|
||||
mach_port_allocate, mach_port_deallocate, mach_port_destroy, mach_port_insert_right,
|
||||
},
|
||||
message::{
|
||||
MACH_MSG_PORT_DESCRIPTOR, MACH_MSG_SUCCESS, MACH_MSG_TYPE_MAKE_SEND, MACH_RCV_MSG,
|
||||
MACH_RCV_TIMED_OUT, MACH_RCV_TIMEOUT, mach_msg, mach_msg_body_t, mach_msg_header_t,
|
||||
mach_msg_port_descriptor_t, mach_msg_trailer_t,
|
||||
},
|
||||
port::{MACH_PORT_NULL, MACH_PORT_RIGHT_RECEIVE, mach_port_t},
|
||||
traps::mach_task_self,
|
||||
};
|
||||
|
||||
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
|
||||
|
||||
pub(super) struct MachSurfaceReceiver {
|
||||
service_name: String,
|
||||
receive_port: mach_port_t,
|
||||
}
|
||||
|
||||
impl MachSurfaceReceiver {
|
||||
pub(super) fn new() -> Result<Self, Box<dyn Error>> {
|
||||
let service_name =
|
||||
format!("com.ely.browser.iosurface.test.{}", ely_domain::ProfileId::new().as_str());
|
||||
let service_name_c = CString::new(service_name.as_str())?;
|
||||
let mut receive_port = MACH_PORT_NULL;
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
let allocate =
|
||||
unsafe { mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &mut receive_port) };
|
||||
if allocate != KERN_SUCCESS {
|
||||
return Err(io::Error::other(format!("mach_port_allocate returned {allocate}")).into());
|
||||
}
|
||||
#[expect(unsafe_code)]
|
||||
let insert = unsafe {
|
||||
mach_port_insert_right(task, receive_port, receive_port, MACH_MSG_TYPE_MAKE_SEND)
|
||||
};
|
||||
if insert != KERN_SUCCESS {
|
||||
destroy_port(receive_port);
|
||||
return Err(
|
||||
io::Error::other(format!("mach_port_insert_right returned {insert}")).into()
|
||||
);
|
||||
}
|
||||
#[expect(unsafe_code)]
|
||||
#[allow(deprecated)]
|
||||
let register = unsafe {
|
||||
bootstrap_register(bootstrap_port, service_name_c.as_ptr() as *mut _, receive_port)
|
||||
};
|
||||
if register != KERN_SUCCESS {
|
||||
destroy_port(receive_port);
|
||||
return Err(io::Error::other(format!("bootstrap_register returned {register}")).into());
|
||||
}
|
||||
Ok(Self { service_name, receive_port })
|
||||
}
|
||||
|
||||
pub(super) fn service_name(&self) -> &str {
|
||||
self.service_name.as_str()
|
||||
}
|
||||
|
||||
pub(super) fn receive(
|
||||
&self,
|
||||
expected_surface_id: u64,
|
||||
timeout: Duration,
|
||||
) -> Result<mach_port_t, Box<dyn Error>> {
|
||||
#[expect(unsafe_code)]
|
||||
let mut received: ReceivedIOSurfacePortMessage = unsafe { mem::zeroed() };
|
||||
#[expect(unsafe_code)]
|
||||
let result = unsafe {
|
||||
mach_msg(
|
||||
&mut received.message.header,
|
||||
MACH_RCV_MSG | MACH_RCV_TIMEOUT,
|
||||
0,
|
||||
mem::size_of::<ReceivedIOSurfacePortMessage>() as u32,
|
||||
self.receive_port,
|
||||
timeout_millis(timeout),
|
||||
MACH_PORT_NULL,
|
||||
)
|
||||
};
|
||||
if result == MACH_RCV_TIMED_OUT {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
format!("Mach receive timed out for surface {expected_surface_id}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if result != MACH_MSG_SUCCESS {
|
||||
return Err(io::Error::other(format!("mach_msg receive returned {result}")).into());
|
||||
}
|
||||
let message = &mut received.message;
|
||||
if message.header.msgh_id != IOSURFACE_PORT_MESSAGE_ID
|
||||
|| message.body.msgh_descriptor_count != 1
|
||||
|| message.surface_port.type_ != MACH_MSG_PORT_DESCRIPTOR as u8
|
||||
|| message.surface_port.name == MACH_PORT_NULL
|
||||
|| message.surface_id != expected_surface_id
|
||||
{
|
||||
destroy_message(message);
|
||||
return Err(io::Error::other("received invalid IOSurface Mach message").into());
|
||||
}
|
||||
Ok(message.surface_port.name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MachSurfaceReceiver {
|
||||
fn drop(&mut self) {
|
||||
destroy_port(self.receive_port);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn verify_iosurface(
|
||||
mach_port: mach_port_t,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let surface = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port)
|
||||
.ok_or_else(|| io::Error::other("IOSurfaceLookupFromMachPort returned null"))?;
|
||||
let actual_width = u32::try_from(surface.width())?;
|
||||
let actual_height = u32::try_from(surface.height())?;
|
||||
deallocate_port(mach_port)?;
|
||||
if actual_width != width || actual_height != height {
|
||||
return Err(io::Error::other(format!(
|
||||
"imported IOSurface was {actual_width}x{actual_height}; expected {width}x{height}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct IOSurfacePortMessage {
|
||||
header: mach_msg_header_t,
|
||||
body: mach_msg_body_t,
|
||||
surface_port: mach_msg_port_descriptor_t,
|
||||
surface_id: u64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ReceivedIOSurfacePortMessage {
|
||||
message: IOSurfacePortMessage,
|
||||
_trailer: mach_msg_trailer_t,
|
||||
}
|
||||
|
||||
fn timeout_millis(timeout: Duration) -> u32 {
|
||||
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(1)
|
||||
}
|
||||
|
||||
fn destroy_message(message: &mut IOSurfacePortMessage) {
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
mach2::message::mach_msg_destroy(&mut message.header);
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy_port(port: mach_port_t) {
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
let _ = mach_port_destroy(task, port);
|
||||
}
|
||||
}
|
||||
|
||||
fn deallocate_port(port: mach_port_t) -> Result<(), Box<dyn Error>> {
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
let result = unsafe { mach_port_deallocate(task, port) };
|
||||
if result != KERN_SUCCESS {
|
||||
return Err(io::Error::other(format!("mach_port_deallocate returned {result}")).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
use std::{
|
||||
error::Error,
|
||||
fs,
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
net::{SocketAddr, TcpListener, TcpStream},
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus, Stdio},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub(super) const WIDTH: u32 = 360;
|
||||
pub(super) const HEIGHT: u32 = 240;
|
||||
pub(super) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2;
|
||||
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
|
||||
const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
|
||||
|
||||
pub(super) fn ensure_request(tab_id: &TabId, profile_id: &ProfileId, url: &str) -> Value {
|
||||
json!({
|
||||
"type": "ensure",
|
||||
"tab_id": tab_id.as_str(),
|
||||
"profile_id": profile_id.as_str(),
|
||||
"url": url,
|
||||
"width": WIDTH,
|
||||
"height": HEIGHT,
|
||||
"page_zoom_percent": 100,
|
||||
"device_pixel_ratio": 1.0,
|
||||
"site_permissions": [],
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) struct Sidecar {
|
||||
child: Child,
|
||||
stdin: Option<ChildStdin>,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
stderr: Option<ChildStderr>,
|
||||
}
|
||||
|
||||
impl Sidecar {
|
||||
pub(super) fn spawn(profile_data_dir: &Path) -> Result<Self, Box<dyn Error>> {
|
||||
let mut sidecar = Self::spawn_without_handshake(profile_data_dir)?;
|
||||
sidecar.handshake()?;
|
||||
Ok(sidecar)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
pub(super) fn spawn_hardware(
|
||||
profile_data_dir: &Path,
|
||||
mach_service: &str,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
let mut sidecar = Self::spawn_process(
|
||||
profile_data_dir,
|
||||
&["--rendering-context", "hardware", "--iosurface-mach-service", mach_service],
|
||||
)?;
|
||||
sidecar.handshake()?;
|
||||
Ok(sidecar)
|
||||
}
|
||||
|
||||
fn handshake(&mut self) -> Result<(), Box<dyn Error>> {
|
||||
let response = self.exchange(&json!({
|
||||
"type": "handshake",
|
||||
"protocol_version": LIVE_PROTOCOL_VERSION,
|
||||
}))?;
|
||||
if response.protocol_version != Some(LIVE_PROTOCOL_VERSION) || response.error.is_some() {
|
||||
return Err(io::Error::other("sidecar protocol handshake failed").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn spawn_without_handshake(profile_data_dir: &Path) -> Result<Self, Box<dyn Error>> {
|
||||
Self::spawn_process(profile_data_dir, &[])
|
||||
}
|
||||
|
||||
fn spawn_process(profile_data_dir: &Path, extra_args: &[&str]) -> Result<Self, Box<dyn Error>> {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"));
|
||||
command.arg("live").arg("--profile-data-dir").arg(profile_data_dir);
|
||||
command.args(extra_args);
|
||||
let mut child =
|
||||
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
|
||||
let stdin =
|
||||
child.stdin.take().ok_or_else(|| io::Error::other("sidecar stdin was not piped"))?;
|
||||
let stdout =
|
||||
child.stdout.take().ok_or_else(|| io::Error::other("sidecar stdout was not piped"))?;
|
||||
let stderr = child.stderr.take();
|
||||
Ok(Self { child, stdin: Some(stdin), stdout: BufReader::new(stdout), stderr })
|
||||
}
|
||||
|
||||
pub(super) fn ensure_and_wait(
|
||||
&mut self,
|
||||
profile_id: &ProfileId,
|
||||
url: &str,
|
||||
expected_title: &str,
|
||||
) -> Result<FramePacket, Box<dyn Error>> {
|
||||
self.ensure_and_wait_matching(profile_id, url, expected_title, |_| true)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_and_wait_visible(
|
||||
&mut self,
|
||||
profile_id: &ProfileId,
|
||||
url: &str,
|
||||
expected_title: &str,
|
||||
) -> Result<FramePacket, Box<dyn Error>> {
|
||||
self.ensure_and_wait_matching(profile_id, url, expected_title, |frame| {
|
||||
frame.non_white_pixel_count > 0 && frame.content_pixel_count > 0
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_and_wait_matching(
|
||||
&mut self,
|
||||
profile_id: &ProfileId,
|
||||
url: &str,
|
||||
expected_title: &str,
|
||||
matches_frame: impl Fn(&FramePacket) -> bool,
|
||||
) -> Result<FramePacket, Box<dyn Error>> {
|
||||
let tab_id = TabId::new();
|
||||
let ensure = ensure_request(&tab_id, profile_id, url);
|
||||
let mut response = self.exchange(&ensure)?;
|
||||
let started_at = Instant::now();
|
||||
let mut latest_title = None;
|
||||
loop {
|
||||
if let Some(error) = response.error {
|
||||
return Err(io::Error::other(format!("sidecar response error: {error}")).into());
|
||||
}
|
||||
if let Some(frame) = response.frame {
|
||||
latest_title = frame.title.clone();
|
||||
if frame.title.as_deref() == Some(expected_title) && matches_frame(&frame) {
|
||||
return Ok(frame);
|
||||
}
|
||||
}
|
||||
if started_at.elapsed() >= RESPONSE_TIMEOUT {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"timed out waiting for title {expected_title:?}; latest={latest_title:?}"
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
response = self.exchange(&json!({ "type": "poll", "tab_id": tab_id.as_str() }))?;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn exchange(&mut self, request: &Value) -> Result<WireResponse, Box<dyn Error>> {
|
||||
let stdin = self
|
||||
.stdin
|
||||
.as_mut()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "sidecar stdin is closed"))?;
|
||||
serde_json::to_writer(&mut *stdin, request)?;
|
||||
stdin.write_all(b"\n")?;
|
||||
stdin.flush()?;
|
||||
read_response(&mut self.stdout)
|
||||
}
|
||||
|
||||
pub(super) fn shutdown(&mut self) -> Result<(), Box<dyn Error>> {
|
||||
let response = self.exchange(&json!({ "type": "shutdown" }))?;
|
||||
if response.protocol_version != Some(LIVE_PROTOCOL_VERSION)
|
||||
|| response.error.is_some()
|
||||
|| response.frame.is_some()
|
||||
{
|
||||
return Err(
|
||||
io::Error::other("shutdown response must be an empty acknowledgement").into()
|
||||
);
|
||||
}
|
||||
self.stdin.take();
|
||||
let status = wait_for_exit(&mut self.child, RESPONSE_TIMEOUT)?;
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut pipe) = self.stderr.take() {
|
||||
pipe.read_to_string(&mut stderr)?;
|
||||
}
|
||||
Err(io::Error::other(format!("sidecar exited with {status}: {stderr}")).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Sidecar {
|
||||
fn drop(&mut self) {
|
||||
if self.child.try_wait().ok().flatten().is_none() {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_exit(child: &mut Child, timeout: Duration) -> Result<ExitStatus, Box<dyn Error>> {
|
||||
let started_at = Instant::now();
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Ok(status);
|
||||
}
|
||||
if started_at.elapsed() >= timeout {
|
||||
child.kill()?;
|
||||
let _ = child.wait();
|
||||
return Err(
|
||||
io::Error::new(io::ErrorKind::TimedOut, "sidecar shutdown timed out").into()
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct WireResponse {
|
||||
pub(super) protocol_version: Option<u32>,
|
||||
pub(super) error: Option<String>,
|
||||
pub(super) frame: Option<FramePacket>,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
pub(super) surface_handle: Option<SurfaceHandle>,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
pub(super) current_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
pub(super) struct FramePacket {
|
||||
pub(super) loaded_url: Option<String>,
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
pub(super) non_white_pixel_count: u64,
|
||||
pub(super) content_pixel_count: u64,
|
||||
pub(super) sample_hash: u64,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
pub(super) rgba_byte_count: usize,
|
||||
_rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct SurfaceHandle {
|
||||
pub(super) mach_port_name: u32,
|
||||
pub(super) surface_id: u64,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
}
|
||||
|
||||
fn read_response(stdout: &mut BufReader<ChildStdout>) -> Result<WireResponse, Box<dyn Error>> {
|
||||
let mut line = String::new();
|
||||
if stdout.read_line(&mut line)? == 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "sidecar response ended").into());
|
||||
}
|
||||
let header: Value = serde_json::from_str(&line)?;
|
||||
let protocol_version = header
|
||||
.get("protocol_version")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| value.try_into().ok());
|
||||
let error = header.get("error").and_then(Value::as_str).map(str::to_string);
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
let surface_handle = header
|
||||
.get("surface_handle")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(|handle| {
|
||||
Ok::<_, Box<dyn Error>>(SurfaceHandle {
|
||||
mach_port_name: u32_field(handle, "mach_port_name")?,
|
||||
surface_id: u64_field(handle, "surface_id")?,
|
||||
width: u32_field(handle, "width")?,
|
||||
height: u32_field(handle, "height")?,
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
let current_surface_id = header.get("current_surface_id").and_then(Value::as_u64);
|
||||
let Some(frame) = header.get("frame").filter(|value| !value.is_null()) else {
|
||||
return Ok(WireResponse {
|
||||
protocol_version,
|
||||
error,
|
||||
frame: None,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
surface_handle,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
current_surface_id,
|
||||
});
|
||||
};
|
||||
let width = u32_field(frame, "width")?;
|
||||
let height = u32_field(frame, "height")?;
|
||||
let rgba_byte_count = usize_field(frame, "rgba_byte_count")?;
|
||||
let expected = usize::try_from(width)?
|
||||
.checked_mul(usize::try_from(height)?)
|
||||
.and_then(|pixels| pixels.checked_mul(4))
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "frame size overflow"))?;
|
||||
if (rgba_byte_count != 0 && rgba_byte_count != expected)
|
||||
|| rgba_byte_count > MAX_FRAME_BYTE_COUNT
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("invalid frame byte count {rgba_byte_count}; expected {expected}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let mut rgba = vec![0; rgba_byte_count];
|
||||
stdout.read_exact(&mut rgba)?;
|
||||
let packet = FramePacket {
|
||||
loaded_url: frame.get("loaded_url").and_then(Value::as_str).map(str::to_string),
|
||||
title: frame.get("title").and_then(Value::as_str).map(str::to_string),
|
||||
width,
|
||||
height,
|
||||
non_white_pixel_count: u64_field(frame, "non_white_pixel_count")?,
|
||||
content_pixel_count: u64_field(frame, "content_pixel_count")?,
|
||||
sample_hash: u64_field(frame, "sample_hash")?,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
rgba_byte_count,
|
||||
_rgba: rgba,
|
||||
};
|
||||
Ok(WireResponse {
|
||||
protocol_version,
|
||||
error,
|
||||
frame: Some(packet),
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
surface_handle,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
current_surface_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn u32_field(value: &Value, name: &str) -> Result<u32, Box<dyn Error>> {
|
||||
Ok(u32::try_from(u64_field(value, name)?)?)
|
||||
}
|
||||
|
||||
fn usize_field(value: &Value, name: &str) -> Result<usize, Box<dyn Error>> {
|
||||
Ok(usize::try_from(u64_field(value, name)?)?)
|
||||
}
|
||||
|
||||
fn u64_field(value: &Value, name: &str) -> Result<u64, Box<dyn Error>> {
|
||||
value.get(name).and_then(Value::as_u64).ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("missing frame field {name}")).into()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) struct TestDirectory(PathBuf);
|
||||
|
||||
impl TestDirectory {
|
||||
pub(super) fn new() -> Result<Self, io::Error> {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ely-sidecar-test-{}-{}",
|
||||
std::process::id(),
|
||||
ProfileId::new()
|
||||
));
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(Self(path))
|
||||
}
|
||||
|
||||
pub(super) fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct TestServer {
|
||||
address: SocketAddr,
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
diagnostics: Arc<Mutex<ServerDiagnostics>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ServerDiagnostics {
|
||||
requests: Vec<String>,
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
pub(super) fn start() -> Result<Self, io::Error> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0))?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let address = listener.local_addr()?;
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let thread_stop = stop.clone();
|
||||
let diagnostics = Arc::new(Mutex::new(ServerDiagnostics::default()));
|
||||
let thread_diagnostics = diagnostics.clone();
|
||||
let thread = thread::spawn(move || serve(listener, &thread_stop, &thread_diagnostics));
|
||||
Ok(Self { address, stop, thread: Some(thread), diagnostics })
|
||||
}
|
||||
|
||||
pub(super) fn url(&self, path: &str) -> String {
|
||||
format!("http://{}{path}", self.address)
|
||||
}
|
||||
|
||||
pub(super) fn diagnostics(&self) -> String {
|
||||
self.diagnostics.lock().map_or_else(
|
||||
|_| "lock poisoned".to_string(),
|
||||
|diagnostics| {
|
||||
format!("requests={:?}, errors={:?}", diagnostics.requests, diagnostics.errors)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn request_count(&self, path: &str) -> usize {
|
||||
self.diagnostics.lock().map_or(0, |diagnostics| {
|
||||
diagnostics
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|request| {
|
||||
request.split_whitespace().nth(1).is_some_and(|url| {
|
||||
url == path
|
||||
|| url.strip_prefix(path).is_some_and(|suffix| suffix.starts_with('?'))
|
||||
})
|
||||
})
|
||||
.count()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = TcpStream::connect(self.address);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(listener: TcpListener, stop: &AtomicBool, diagnostics: &Mutex<ServerDiagnostics>) {
|
||||
while !stop.load(Ordering::Acquire) {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
if let Err(error) = serve_connection(stream, diagnostics)
|
||||
&& let Ok(mut diagnostics) = diagnostics.lock()
|
||||
{
|
||||
diagnostics.errors.push(error.to_string());
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_connection(
|
||||
stream: TcpStream,
|
||||
diagnostics: &Mutex<ServerDiagnostics>,
|
||||
) -> Result<(), io::Error> {
|
||||
stream.set_nonblocking(false)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut request_line = String::new();
|
||||
reader.read_line(&mut request_line)?;
|
||||
if let Ok(mut diagnostics) = diagnostics.lock() {
|
||||
diagnostics.requests.push(request_line.trim().to_string());
|
||||
}
|
||||
loop {
|
||||
let mut header_line = String::new();
|
||||
if reader.read_line(&mut header_line)? == 0 || header_line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let path = request_line.split_whitespace().nth(1).unwrap_or("/");
|
||||
let is_set = path == "/set";
|
||||
let body = if is_set {
|
||||
SET_PAGE
|
||||
} else if path.starts_with("/history") {
|
||||
HISTORY_PAGE
|
||||
} else if path == "/white" {
|
||||
WHITE_PAGE
|
||||
} else {
|
||||
READ_PAGE
|
||||
};
|
||||
let cookie_header =
|
||||
if is_set { "Set-Cookie: ely_cookie=persisted; Path=/; SameSite=Lax\r\n" } else { "" };
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\n{cookie_header}Connection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
reader.get_mut().write_all(response.as_bytes())?;
|
||||
reader.get_mut().flush()
|
||||
}
|
||||
|
||||
const SET_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font:24px sans-serif;color:#111;background:#fff}</style><body>Profile persistence</body><script>localStorage.setItem('ely_storage','persisted');const cookie=document.cookie.includes('ely_cookie=persisted')?'yes':'no';const storage=localStorage.getItem('ely_storage')==='persisted'?'yes':'no';document.title=`stored-cookie-${cookie}-storage-${storage}`;</script>"#;
|
||||
|
||||
const READ_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font:24px sans-serif;color:#111;background:#fff}</style><body>Profile persistence</body><script>const cookie=document.cookie.includes('ely_cookie=persisted')?'yes':'no';const storage=localStorage.getItem('ely_storage')==='persisted'?'yes':'no';document.title=`read-cookie-${cookie}-storage-${storage}`;</script>"#;
|
||||
|
||||
const HISTORY_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font:24px sans-serif;color:#111;background:#fff}</style><body>History mutation</body><script>history.replaceState({},'', '/history?state=1');document.title='history-ready';</script>"#;
|
||||
|
||||
const WHITE_PAGE: &str = r#"<!doctype html><title>white-ready</title><style>html,body{margin:0;width:100%;height:100%;background:#fff}</style>"#;
|
||||
@@ -4,8 +4,6 @@ use std::{
|
||||
env,
|
||||
error::Error,
|
||||
process::{Command, Stdio},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText};
|
||||
@@ -27,9 +25,9 @@ const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
|
||||
const SOFTWARE_HOST_CHILD_ENV: &str = "ELY_SERVO_SOFTWARE_HOST_CHILD";
|
||||
const DPR_VIEWPORT_CHILD_ENV: &str = "ELY_SERVO_DPR_VIEWPORT_CHILD";
|
||||
const CLICK_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EClick%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Clicked%27%3Bthis.textContent%3D%27Clicked%27%3B%22%3ETap%3C%2Fbutton%3E";
|
||||
const DRAG_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3EDrag%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20id%3Dbox%3EDrag%3C%2Fbutton%3E%3Cscript%3Elet%20dragging%3Dfalse%3Bconst%20box%3Ddocument.getElementById%28%27box%27%29%3BaddEventListener%28%27mousedown%27%2Cevent%3D%3E%7Bif%28event.target%3D%3D%3Dbox%29%7Bdragging%3Dtrue%3B%7D%7D%29%3BaddEventListener%28%27mousemove%27%2Cevent%3D%3E%7Bif%28dragging%26%26event.clientX%3E280%29%7Bdocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Dragged%27%3Bbox.textContent%3D%27Dragged%27%3B%7D%7D%29%3BaddEventListener%28%27mouseup%27%2C%28%29%3D%3E%7Bdragging%3Dfalse%3B%7D%29%3B%3C%2Fscript%3E";
|
||||
const TOUCH_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onpointerdown%3D%22if%28%21document.body.dataset.pointerType%29%7Bdocument.body.dataset.pointerType%3Devent.pointerType%3B%7D%22%20onclick%3D%22if%28document.body.dataset.pointerType%21%3D%3D%27touch%27%29%7Bdocument.title%3Ddocument.body.dataset.pointerType%3Breturn%3B%7Ddocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E";
|
||||
const TEXT_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E";
|
||||
const DRAG_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3EDrag%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f6d365%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20id%3Dbox%3EDrag%3C%2Fbutton%3E%3Cscript%3Elet%20dragging%3Dfalse%3Bconst%20box%3Ddocument.getElementById%28%27box%27%29%3BaddEventListener%28%27mousedown%27%2Cevent%3D%3E%7Bif%28event.target%3D%3D%3Dbox%29%7Bdragging%3Dtrue%3B%7D%7D%29%3BaddEventListener%28%27mousemove%27%2Cevent%3D%3E%7Bif%28dragging%26%26event.clientX%3E280%29%7Bdocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Dragged%27%3Bbox.textContent%3D%27Dragged%27%3B%7D%7D%29%3BaddEventListener%28%27mouseup%27%2C%28%29%3D%3E%7Bdragging%3Dfalse%3B%7D%29%3B%3C%2Fscript%3E";
|
||||
const TOUCH_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23c7f5d9%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onpointerdown%3D%22if%28%21document.body.dataset.pointerType%29%7Bdocument.body.dataset.pointerType%3Devent.pointerType%3B%7D%22%20onclick%3D%22if%28document.body.dataset.pointerType%21%3D%3D%27touch%27%29%7Bdocument.title%3Ddocument.body.dataset.pointerType%3Breturn%3B%7Ddocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E";
|
||||
const TEXT_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23d9e8ff%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E";
|
||||
const TEXT_PROBE_VALUE: &str = "ely42";
|
||||
|
||||
struct PrdSiteCompatibilityCase {
|
||||
@@ -258,15 +256,27 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
||||
host.click(MouseClickRequest { webview_id: webview_id.clone(), x: 160, y: 120 })?;
|
||||
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
|
||||
let snapshot = wait_for_rendered_webview_with_title(
|
||||
&mut host,
|
||||
&webview_id,
|
||||
Some(previous_frame_hash),
|
||||
"Clicked",
|
||||
)?;
|
||||
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
|
||||
assert_eq!(snapshot.title(), Some("Clicked"), "snapshot: {snapshot:?}");
|
||||
assert_rendered_frame_has_content(&host, "data:text/html clicked", 1)?;
|
||||
assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash);
|
||||
|
||||
let tab_id = TabId::new();
|
||||
let url = UrlText::parse(DRAG_PROBE_URL)?;
|
||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
||||
host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?;
|
||||
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?;
|
||||
let snapshot = wait_for_rendered_webview_with_center_pixel(
|
||||
&mut host,
|
||||
&webview_id,
|
||||
Some(previous_frame_hash),
|
||||
[246, 211, 101],
|
||||
)?;
|
||||
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
|
||||
assert_rendered_frame_has_content(&host, "data:text/html drag", 1)?;
|
||||
|
||||
@@ -278,21 +288,38 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
|
||||
to_x: 320,
|
||||
to_y: 120,
|
||||
})?;
|
||||
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
|
||||
let snapshot = wait_for_rendered_webview_with_title(
|
||||
&mut host,
|
||||
&webview_id,
|
||||
Some(previous_frame_hash),
|
||||
"Dragged",
|
||||
)?;
|
||||
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
|
||||
assert_eq!(snapshot.title(), Some("Dragged"), "snapshot: {snapshot:?}");
|
||||
assert_rendered_frame_has_content(&host, "data:text/html dragged", 1)?;
|
||||
assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash);
|
||||
|
||||
let tab_id = TabId::new();
|
||||
let url = UrlText::parse(TOUCH_PROBE_URL)?;
|
||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
||||
host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?;
|
||||
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?;
|
||||
let snapshot = wait_for_rendered_webview_with_center_pixel(
|
||||
&mut host,
|
||||
&webview_id,
|
||||
Some(previous_frame_hash),
|
||||
[199, 245, 217],
|
||||
)?;
|
||||
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
|
||||
assert_rendered_frame_has_content(&host, "data:text/html touch", 1)?;
|
||||
|
||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
||||
host.touch_tap(TouchTapRequest { webview_id: webview_id.clone(), x: 160, y: 120 })?;
|
||||
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
|
||||
let snapshot = wait_for_rendered_webview_with_title(
|
||||
&mut host,
|
||||
&webview_id,
|
||||
Some(previous_frame_hash),
|
||||
"Touched",
|
||||
)?;
|
||||
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
|
||||
assert_eq!(snapshot.title(), Some("Touched"), "snapshot: {snapshot:?}");
|
||||
assert_rendered_frame_has_content(&host, "data:text/html touched", 1)?;
|
||||
@@ -300,8 +327,14 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
let tab_id = TabId::new();
|
||||
let url = UrlText::parse(TEXT_PROBE_URL)?;
|
||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
||||
host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?;
|
||||
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?;
|
||||
let snapshot = wait_for_rendered_webview_with_center_pixel(
|
||||
&mut host,
|
||||
&webview_id,
|
||||
Some(previous_frame_hash),
|
||||
[217, 232, 255],
|
||||
)?;
|
||||
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
|
||||
assert_rendered_frame_has_content(&host, "data:text/html input", 1)?;
|
||||
|
||||
@@ -311,7 +344,12 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
|
||||
webview_id: webview_id.clone(),
|
||||
text: TEXT_PROBE_VALUE.to_string(),
|
||||
})?;
|
||||
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
|
||||
let snapshot = wait_for_rendered_webview_with_center_pixel(
|
||||
&mut host,
|
||||
&webview_id,
|
||||
Some(previous_frame_hash),
|
||||
[0, 57, 255],
|
||||
)?;
|
||||
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
|
||||
assert_rendered_frame_has_content(&host, "data:text/html typed", 1)?;
|
||||
assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash);
|
||||
@@ -390,111 +428,6 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_rendered_webview(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
previous_frame_hash: Option<u64>,
|
||||
) -> Result<ely_servo_host::WebViewSnapshot, Box<dyn Error>> {
|
||||
let mut painted_since_request = false;
|
||||
|
||||
for _ in 0..5_000 {
|
||||
host.tick();
|
||||
let snapshot = host.snapshot(webview_id)?;
|
||||
if snapshot.has_pending_frame() {
|
||||
host.paint(webview_id)?;
|
||||
painted_since_request = true;
|
||||
}
|
||||
|
||||
let snapshot = host.snapshot(webview_id)?;
|
||||
let has_rendered_current_request = host.last_rendered_frame().is_ok_and(|frame| {
|
||||
painted_since_request
|
||||
&& Some(frame.sample_hash()) != previous_frame_hash
|
||||
&& frame.non_white_pixel_count() > 0
|
||||
});
|
||||
|
||||
if snapshot.state() == &WebViewState::Complete && has_rendered_current_request {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
|
||||
Err(format!("timed out waiting for rendered webview: {:?}", host.snapshot(webview_id)?).into())
|
||||
}
|
||||
|
||||
fn assert_rendered_frame_has_content(
|
||||
host: &SoftwareServoHost,
|
||||
label: &str,
|
||||
minimum_content_pixels: u64,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
assert_rendered_frame_has_dimensions_and_content(
|
||||
host,
|
||||
label,
|
||||
INITIAL_WIDTH,
|
||||
INITIAL_HEIGHT,
|
||||
minimum_content_pixels,
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_rendered_frame_has_dimensions_and_content(
|
||||
host: &SoftwareServoHost,
|
||||
label: &str,
|
||||
expected_width: u32,
|
||||
expected_height: u32,
|
||||
minimum_content_pixels: u64,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let frame = host.last_rendered_frame()?;
|
||||
assert_frame_has_dimensions_and_content(
|
||||
&frame,
|
||||
label,
|
||||
expected_width,
|
||||
expected_height,
|
||||
minimum_content_pixels,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_frame_has_dimensions_and_content(
|
||||
frame: &ely_servo_host::RenderedFrame,
|
||||
label: &str,
|
||||
expected_width: u32,
|
||||
expected_height: u32,
|
||||
minimum_content_pixels: u64,
|
||||
) {
|
||||
assert_eq!(frame.width(), expected_width, "{label}: {frame:?}");
|
||||
assert_eq!(frame.height(), expected_height, "{label}: {frame:?}");
|
||||
assert!(frame.opaque_pixel_count() > 0, "{label}: {frame:?}");
|
||||
assert!(frame.non_white_pixel_count() > 0, "{label}: {frame:?}");
|
||||
assert!(frame.content_pixel_count() >= minimum_content_pixels, "{label}: {frame:?}");
|
||||
assert_ne!(frame.sample_hash(), 0, "{label}: {frame:?}");
|
||||
}
|
||||
|
||||
fn center_pixel_rgb(frame: &ely_servo_host::RenderedFrame) -> [u8; 3] {
|
||||
let x = frame.width() / 2;
|
||||
let y = frame.height() / 2;
|
||||
let index = ((y * frame.width() + x) * 4) as usize;
|
||||
let rgba = &frame.rgba_bytes()[index..index + 4];
|
||||
[rgba[0], rgba[1], rgba[2]]
|
||||
}
|
||||
|
||||
fn viewport_probe_url(min_width_threshold: u32) -> String {
|
||||
let html = format!(
|
||||
"<!doctype html><title>DPR Probe</title><style>\
|
||||
html,body{{margin:0;width:100%;height:100%;background:rgb(238,32,77);}}\
|
||||
@media (min-width:{min_width_threshold}px){{html,body{{background:rgb(0,57,255);}}}}\
|
||||
</style>",
|
||||
);
|
||||
format!("data:text/html,{}", percent_encode_for_data_url(&html))
|
||||
}
|
||||
|
||||
fn percent_encode_for_data_url(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.map(|byte| match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
(byte as char).to_string()
|
||||
}
|
||||
_ => format!("%{byte:02X}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[path = "software_host/support.rs"]
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::{error::Error, thread, time::Duration};
|
||||
|
||||
use ely_servo_host::{RenderedFrame, ServoHost, SoftwareServoHost, WebViewSnapshot, WebViewState};
|
||||
|
||||
use super::{INITIAL_HEIGHT, INITIAL_WIDTH};
|
||||
|
||||
pub(super) fn wait_for_rendered_webview(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
previous_frame_hash: Option<u64>,
|
||||
) -> Result<WebViewSnapshot, Box<dyn Error>> {
|
||||
wait_for_rendered_webview_matching(host, webview_id, previous_frame_hash, |_| true, |_| true)
|
||||
}
|
||||
|
||||
pub(super) fn wait_for_rendered_webview_with_title(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
previous_frame_hash: Option<u64>,
|
||||
expected_title: &str,
|
||||
) -> Result<WebViewSnapshot, Box<dyn Error>> {
|
||||
wait_for_rendered_webview_matching(
|
||||
host,
|
||||
webview_id,
|
||||
previous_frame_hash,
|
||||
|snapshot| snapshot.title() == Some(expected_title),
|
||||
|_| true,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn wait_for_rendered_webview_with_center_pixel(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
previous_frame_hash: Option<u64>,
|
||||
expected_rgb: [u8; 3],
|
||||
) -> Result<WebViewSnapshot, Box<dyn Error>> {
|
||||
wait_for_rendered_webview_matching(
|
||||
host,
|
||||
webview_id,
|
||||
previous_frame_hash,
|
||||
|_| true,
|
||||
|frame| center_pixel_rgb(frame) == expected_rgb,
|
||||
)
|
||||
}
|
||||
|
||||
fn wait_for_rendered_webview_matching(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
previous_frame_hash: Option<u64>,
|
||||
snapshot_matches: impl Fn(&WebViewSnapshot) -> bool,
|
||||
frame_matches: impl Fn(&RenderedFrame) -> bool,
|
||||
) -> Result<WebViewSnapshot, Box<dyn Error>> {
|
||||
let mut painted_since_request = false;
|
||||
|
||||
for _ in 0..5_000 {
|
||||
host.tick();
|
||||
let snapshot = host.snapshot(webview_id)?;
|
||||
if snapshot.has_pending_frame() {
|
||||
host.paint(webview_id)?;
|
||||
painted_since_request = true;
|
||||
}
|
||||
|
||||
let snapshot = host.snapshot(webview_id)?;
|
||||
let has_rendered_current_request = host.last_rendered_frame().is_ok_and(|frame| {
|
||||
painted_since_request
|
||||
&& Some(frame.sample_hash()) != previous_frame_hash
|
||||
&& frame.non_white_pixel_count() > 0
|
||||
&& frame_matches(&frame)
|
||||
});
|
||||
|
||||
if snapshot.state() == &WebViewState::Complete
|
||||
&& has_rendered_current_request
|
||||
&& snapshot_matches(&snapshot)
|
||||
{
|
||||
return Ok(snapshot);
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
|
||||
Err(format!("timed out waiting for rendered webview: {:?}", host.snapshot(webview_id)?).into())
|
||||
}
|
||||
|
||||
pub(super) fn assert_rendered_frame_has_content(
|
||||
host: &SoftwareServoHost,
|
||||
label: &str,
|
||||
minimum_content_pixels: u64,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
assert_rendered_frame_has_dimensions_and_content(
|
||||
host,
|
||||
label,
|
||||
INITIAL_WIDTH,
|
||||
INITIAL_HEIGHT,
|
||||
minimum_content_pixels,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn assert_rendered_frame_has_dimensions_and_content(
|
||||
host: &SoftwareServoHost,
|
||||
label: &str,
|
||||
expected_width: u32,
|
||||
expected_height: u32,
|
||||
minimum_content_pixels: u64,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let frame = host.last_rendered_frame()?;
|
||||
assert_frame_has_dimensions_and_content(
|
||||
&frame,
|
||||
label,
|
||||
expected_width,
|
||||
expected_height,
|
||||
minimum_content_pixels,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_frame_has_dimensions_and_content(
|
||||
frame: &RenderedFrame,
|
||||
label: &str,
|
||||
expected_width: u32,
|
||||
expected_height: u32,
|
||||
minimum_content_pixels: u64,
|
||||
) {
|
||||
assert_eq!(frame.width(), expected_width, "{label}: {frame:?}");
|
||||
assert_eq!(frame.height(), expected_height, "{label}: {frame:?}");
|
||||
assert!(frame.opaque_pixel_count() > 0, "{label}: {frame:?}");
|
||||
assert!(frame.non_white_pixel_count() > 0, "{label}: {frame:?}");
|
||||
assert!(frame.content_pixel_count() >= minimum_content_pixels, "{label}: {frame:?}");
|
||||
assert_ne!(frame.sample_hash(), 0, "{label}: {frame:?}");
|
||||
}
|
||||
|
||||
pub(super) fn center_pixel_rgb(frame: &RenderedFrame) -> [u8; 3] {
|
||||
let x = frame.width() / 2;
|
||||
let y = frame.height() / 2;
|
||||
let index = ((y * frame.width() + x) * 4) as usize;
|
||||
let rgba = &frame.rgba_bytes()[index..index + 4];
|
||||
[rgba[0], rgba[1], rgba[2]]
|
||||
}
|
||||
|
||||
pub(super) fn viewport_probe_url(min_width_threshold: u32) -> String {
|
||||
let html = format!(
|
||||
"<!doctype html><title>DPR Probe</title><style>\
|
||||
html,body{{margin:0;width:100%;height:100%;background:rgb(238,32,77);}}\
|
||||
@media (min-width:{min_width_threshold}px){{html,body{{background:rgb(0,57,255);}}}}\
|
||||
</style>",
|
||||
);
|
||||
format!("data:text/html,{}", percent_encode_for_data_url(&html))
|
||||
}
|
||||
|
||||
fn percent_encode_for_data_url(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.map(|byte| match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
(byte as char).to_string()
|
||||
}
|
||||
_ => format!("%{byte:02X}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Reference in New Issue
Block a user