update
This commit is contained in:
@@ -15,6 +15,7 @@ ed25519-dalek.workspace = true
|
||||
ely_browser_core = { path = "../ely_browser_core" }
|
||||
ely_design_system = { path = "../ely_design_system" }
|
||||
ely_domain = { path = "../ely_domain" }
|
||||
ely_servo_host = { path = "../ely_servo_host", features = ["servo-engine"] }
|
||||
ely_sync_client = { path = "../ely_sync_client" }
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
@@ -29,20 +30,6 @@ tracing-subscriber.workspace = true
|
||||
ureq.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
# Pin to the same minor as gpui 0.2.2 so we share its `CVPixelBuffer`
|
||||
# type — gpui's `Surface::From<CVPixelBuffer>` only matches the
|
||||
# version it built against.
|
||||
core-video = "0.4"
|
||||
io-surface = "0.16"
|
||||
mach2 = "0.6"
|
||||
objc2 = "0.6"
|
||||
objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] }
|
||||
objc2-foundation = { version = "0.3.1", features = ["NSDictionary", "NSString", "NSValue"] }
|
||||
objc2-io-surface = "0.3.2"
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
|
||||
@@ -324,15 +324,17 @@ fn quit(_: &Quit, cx: &mut App) {
|
||||
cx.quit();
|
||||
}
|
||||
|
||||
const DEFAULT_TRACING_FILTER: &str = "ely_app=info,ely_servo_host=warn,ely=info";
|
||||
|
||||
/// Install the global tracing subscriber. `RUST_LOG` drives the
|
||||
/// filter; absent it, only `warn` and above leak through so day-to-day
|
||||
/// runs stay quiet. The perf target is silent by default —
|
||||
/// `RUST_LOG=ely::servo::perf=info` flips on the frame-time stream
|
||||
/// without touching the rest of the app. We swallow re-init errors so
|
||||
/// tests that share the process state with main don't blow up.
|
||||
/// filter; absent it, app-owned targets stay visible and Servo internals
|
||||
/// stay opt-in. `RUST_LOG=ely::servo::perf=info` flips on the frame-time
|
||||
/// stream. We swallow re-init errors so tests that share process state
|
||||
/// with main can reuse this path.
|
||||
fn init_tracing() {
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"));
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(DEFAULT_TRACING_FILTER));
|
||||
let _ = fmt().with_env_filter(filter).with_target(true).try_init();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ffi::CString,
|
||||
mem,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use mach2::{
|
||||
bootstrap::{bootstrap_port, bootstrap_register},
|
||||
kern_return::KERN_SUCCESS,
|
||||
mach_port::{mach_port_allocate, mach_port_destroy, mach_port_insert_right},
|
||||
message::{
|
||||
MACH_MSG_PORT_DESCRIPTOR, MACH_MSG_SUCCESS, MACH_MSG_TIMEOUT_NONE, 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,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
|
||||
const SERVICE_PREFIX: &str = "com.ely.browser.iosurface";
|
||||
|
||||
pub(crate) struct IOSurfaceMachReceiver {
|
||||
service_name: String,
|
||||
receive_port: mach_port_t,
|
||||
pending_ports: BTreeMap<u64, mach_port_t>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum IOSurfaceMachError {
|
||||
#[error("Mach service name contains an interior nul byte")]
|
||||
InvalidServiceName,
|
||||
|
||||
#[error("mach_port_allocate returned {code}")]
|
||||
AllocatePort { code: i32 },
|
||||
|
||||
#[error("mach_port_insert_right returned {code}")]
|
||||
InsertSendRight { code: i32 },
|
||||
|
||||
#[error("bootstrap_register returned {code}")]
|
||||
RegisterService { code: i32 },
|
||||
|
||||
#[error("mach_msg receive timed out for IOSurface surface {surface_id:#x}")]
|
||||
ReceiveTimedOut { surface_id: u64 },
|
||||
|
||||
#[error("mach_msg receive returned {code}")]
|
||||
Receive { code: i32 },
|
||||
|
||||
#[error("received unexpected Mach message id {message_id}")]
|
||||
UnexpectedMessage { message_id: i32 },
|
||||
|
||||
#[error("received invalid IOSurface Mach message")]
|
||||
InvalidMessage,
|
||||
}
|
||||
|
||||
impl IOSurfaceMachReceiver {
|
||||
pub(crate) fn new() -> Result<Self, IOSurfaceMachError> {
|
||||
let service_name = unique_service_name();
|
||||
let service_name_c = CString::new(service_name.as_str())
|
||||
.map_err(|_| IOSurfaceMachError::InvalidServiceName)?;
|
||||
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(IOSurfaceMachError::AllocatePort { code: allocate });
|
||||
}
|
||||
|
||||
#[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(IOSurfaceMachError::InsertSendRight { code: insert });
|
||||
}
|
||||
|
||||
#[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(IOSurfaceMachError::RegisterService { code: register });
|
||||
}
|
||||
|
||||
Ok(Self { service_name, receive_port, pending_ports: BTreeMap::new() })
|
||||
}
|
||||
|
||||
pub(crate) fn service_name(&self) -> &str {
|
||||
self.service_name.as_str()
|
||||
}
|
||||
|
||||
pub(crate) fn receive_port_for_surface(
|
||||
&mut self,
|
||||
surface_id: u64,
|
||||
timeout: Duration,
|
||||
) -> Result<mach_port_t, IOSurfaceMachError> {
|
||||
if let Some(port) = self.pending_ports.remove(&surface_id) {
|
||||
return Ok(port);
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
|
||||
}
|
||||
let Some(received) = self.receive_one(remaining)? else {
|
||||
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
|
||||
};
|
||||
if received.surface_id == surface_id {
|
||||
return Ok(received.mach_port);
|
||||
}
|
||||
self.pending_ports.insert(received.surface_id, received.mach_port);
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_one(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<ReceivedSurfacePort>, IOSurfaceMachError> {
|
||||
#[expect(unsafe_code)]
|
||||
let mut received_message: ReceivedIOSurfacePortMessage = unsafe { mem::zeroed() };
|
||||
let timeout_ms = timeout_millis(timeout);
|
||||
#[expect(unsafe_code)]
|
||||
let result = unsafe {
|
||||
mach_msg(
|
||||
&mut received_message.message.header,
|
||||
MACH_RCV_MSG | MACH_RCV_TIMEOUT,
|
||||
0,
|
||||
mem::size_of::<ReceivedIOSurfacePortMessage>() as u32,
|
||||
self.receive_port,
|
||||
timeout_ms,
|
||||
MACH_PORT_NULL,
|
||||
)
|
||||
};
|
||||
|
||||
if result == MACH_RCV_TIMED_OUT {
|
||||
return Ok(None);
|
||||
}
|
||||
if result != MACH_MSG_SUCCESS {
|
||||
return Err(IOSurfaceMachError::Receive { code: result });
|
||||
}
|
||||
let message = &mut received_message.message;
|
||||
if message.header.msgh_id != IOSURFACE_PORT_MESSAGE_ID {
|
||||
destroy_message(message);
|
||||
return Err(IOSurfaceMachError::UnexpectedMessage {
|
||||
message_id: message.header.msgh_id,
|
||||
});
|
||||
}
|
||||
if message.body.msgh_descriptor_count != 1
|
||||
|| message.surface_port.type_ != MACH_MSG_PORT_DESCRIPTOR as u8
|
||||
|| message.surface_port.name == MACH_PORT_NULL
|
||||
{
|
||||
destroy_message(message);
|
||||
return Err(IOSurfaceMachError::InvalidMessage);
|
||||
}
|
||||
|
||||
Ok(Some(ReceivedSurfacePort {
|
||||
surface_id: message.surface_id,
|
||||
mach_port: message.surface_port.name,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IOSurfaceMachReceiver {
|
||||
fn drop(&mut self) {
|
||||
for port in std::mem::take(&mut self.pending_ports).into_values() {
|
||||
deallocate_port(port);
|
||||
}
|
||||
destroy_port(self.receive_port);
|
||||
}
|
||||
}
|
||||
|
||||
struct ReceivedSurfacePort {
|
||||
surface_id: u64,
|
||||
mach_port: mach_port_t,
|
||||
}
|
||||
|
||||
#[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 unique_service_name() -> String {
|
||||
format!("{SERVICE_PREFIX}.{}", Uuid::now_v7().as_simple())
|
||||
}
|
||||
|
||||
fn timeout_millis(timeout: Duration) -> u32 {
|
||||
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(MACH_MSG_TIMEOUT_NONE + 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) {
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
let _ = mach2::mach_port::mach_port_deallocate(task, port);
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
//! macOS-only import of cross-process IOSurface handles into
|
||||
//! `CVPixelBuffer`s that preserve the sidecar's IOSurface identity.
|
||||
//!
|
||||
//! `T10.4` originally imported the IOSurface into an `MTLTexture`
|
||||
//! directly. GPUI 0.2.2 exposes `Window::paint_surface` /
|
||||
//! `elements::surface::Surface` for `CVPixelBuffer`; the local GPUI
|
||||
//! patch adds a BGRA fragment pipeline for Servo's hardware
|
||||
//! IOSurfaces, so this cache is the renderer-side handoff point.
|
||||
//!
|
||||
//! Lifetime contract:
|
||||
//!
|
||||
//! * `IOSurfaceCreateMachPort` (sidecar side) gives the receiver a
|
||||
//! send right whose refcount is 1 in our task. After we resolve
|
||||
//! the surface and wrap it in a CVPixelBuffer, the mach port has
|
||||
//! done its job.
|
||||
//! * `CVPixelBufferCreateWithIOSurface` retains the IOSurface for
|
||||
//! the pixel buffer's lifetime. We `mach_port_deallocate`
|
||||
//! immediately so the receiver process doesn't accumulate idle
|
||||
//! mach send rights.
|
||||
//! * Dropping `IOSurfaceCache` releases every cached
|
||||
//! `CVPixelBuffer`, which in turn releases each retained
|
||||
//! IOSurface. The sidecar still holds its own retain via surfman,
|
||||
//! so the IOSurface itself outlives our cache for as long as the
|
||||
//! sidecar keeps painting.
|
||||
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use core_foundation::base::TCFType as _;
|
||||
use core_video::pixel_buffer::CVPixelBuffer;
|
||||
#[allow(deprecated)]
|
||||
use io_surface::IOSurface;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Cache of imported `CVPixelBuffer`s keyed by IOSurface identity.
|
||||
/// Constructed lazily by the renderer-side client on the first
|
||||
/// hardware-path frame.
|
||||
pub(crate) struct IOSurfaceCache {
|
||||
pixel_buffers: HashMap<u64, CachedPixelBuffer>,
|
||||
}
|
||||
|
||||
// SAFETY: CVPixelBuffer wraps CVPixelBufferRef, a CoreFoundation type
|
||||
// Apple documents as safe to share across threads. The cache is owned
|
||||
// by ServoLiveClient which now lives on the LiveRuntimeWorker thread,
|
||||
// so the auto-Send check (rightly) rejects the raw pointer inside the
|
||||
// crate's `CVPixelBuffer`. The pointer is atomically refcounted CFTypeRef
|
||||
// and only mutated via Mach IPC, which is itself thread-safe.
|
||||
#[expect(unsafe_code)]
|
||||
unsafe impl Send for IOSurfaceCache {}
|
||||
|
||||
struct CachedPixelBuffer {
|
||||
pixel_buffer: CVPixelBuffer,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum SurfaceImportError {
|
||||
#[error("IOSurfaceLookupFromMachPort returned null for port 0x{port:x}")]
|
||||
LookupFailed { port: u32 },
|
||||
#[error("CVPixelBufferCreateWithIOSurface returned status {status}")]
|
||||
PixelBufferBuildFailed { status: i32 },
|
||||
}
|
||||
|
||||
impl IOSurfaceCache {
|
||||
pub fn new() -> Self {
|
||||
Self { pixel_buffers: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Import an IOSurface published by the sidecar's
|
||||
/// `surface_handle` field. Idempotent on `surface_id` plus pixel
|
||||
/// dimensions: duplicate handles for the same sized IOSurface are
|
||||
/// discarded, while a resized IOSurface that reuses the same
|
||||
/// `surface_id` replaces the cached pixel buffer.
|
||||
#[cfg(test)]
|
||||
pub fn import(
|
||||
&mut self,
|
||||
mach_port_name: u32,
|
||||
surface_id: u64,
|
||||
) -> Result<(), SurfaceImportError> {
|
||||
let pixel_buffer = import_pixel_buffer_from_mach_port(mach_port_name)?;
|
||||
self.insert_pixel_buffer(surface_id, pixel_buffer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn insert_pixel_buffer(&mut self, surface_id: u64, pixel_buffer: CVPixelBuffer) {
|
||||
let width = pixel_buffer.get_width() as u32;
|
||||
let height = pixel_buffer.get_height() as u32;
|
||||
|
||||
if self
|
||||
.pixel_buffers
|
||||
.get(&surface_id)
|
||||
.is_some_and(|cached| cached.width == width && cached.height == height)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.pixel_buffers.insert(surface_id, CachedPixelBuffer { pixel_buffer, width, height });
|
||||
}
|
||||
|
||||
/// Look up an already-imported pixel buffer by `surface_id`. The
|
||||
/// receiver's per-frame `current_surface_id` selects which of the
|
||||
/// swap chain's rotating front/back surfaces to sample. Returns a
|
||||
/// clone (CVPixelBuffer is reference-counted; cloning is a cheap
|
||||
/// atomic increment) so the caller can hand it to GPUI's
|
||||
/// `surface(...)` element without holding a borrow on the cache.
|
||||
pub fn pixel_buffer_for(&self, surface_id: u64) -> Option<CVPixelBuffer> {
|
||||
self.pixel_buffers.get(&surface_id).map(|cached| cached.pixel_buffer.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn surface_ids(&self) -> Vec<u64> {
|
||||
self.pixel_buffers.keys().copied().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn cached_surface_count(&self) -> usize {
|
||||
self.pixel_buffers.len()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn import_pixel_buffer_from_mach_port(
|
||||
mach_port_name: u32,
|
||||
) -> Result<CVPixelBuffer, SurfaceImportError> {
|
||||
let result = build_pixel_buffer_from_mach_port(mach_port_name);
|
||||
deallocate_mach_port(mach_port_name);
|
||||
result
|
||||
}
|
||||
|
||||
fn build_pixel_buffer_from_mach_port(
|
||||
mach_port_name: u32,
|
||||
) -> Result<CVPixelBuffer, SurfaceImportError> {
|
||||
let Some(iosurface) = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port_name)
|
||||
else {
|
||||
return Err(SurfaceImportError::LookupFailed { port: mach_port_name });
|
||||
};
|
||||
|
||||
// Both objc2-io-surface and the legacy `io_surface` crate wrap
|
||||
// the same C `__IOSurface` pointer. CVPixelBufferCreateWithIOSurface
|
||||
// (via core-video) expects the legacy crate's wrapper. Reach for
|
||||
// the raw pointer and let TCFType CFRetain it independently so
|
||||
// both Rust handles can drop without double-freeing.
|
||||
let raw_ptr: *const std::ffi::c_void =
|
||||
(&*iosurface) as *const objc2_io_surface::IOSurfaceRef as *const std::ffi::c_void;
|
||||
#[allow(deprecated)]
|
||||
let io_surface_view: IOSurface = {
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
IOSurface::wrap_under_get_rule(raw_ptr as io_surface::IOSurfaceRef)
|
||||
}
|
||||
};
|
||||
|
||||
CVPixelBuffer::from_io_surface(&io_surface_view, None)
|
||||
.map_err(|status| SurfaceImportError::PixelBufferBuildFailed { status })
|
||||
}
|
||||
|
||||
/// Release one send right against the mach port we received. The
|
||||
/// IOSurface itself stays alive because the `CVPixelBuffer` (or the
|
||||
/// sidecar's surfman) still retain it.
|
||||
fn deallocate_mach_port(port: u32) {
|
||||
#[expect(unsafe_code)]
|
||||
let result = unsafe { mach_port_deallocate(mach_task_self_, port) };
|
||||
if result != KERN_SUCCESS {
|
||||
tracing::warn!(
|
||||
target: "ely::servo::iosurface",
|
||||
mach_port_name = port,
|
||||
kern_result = result,
|
||||
"mach_port_deallocate returned non-success",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const KERN_SUCCESS: i32 = 0;
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
unsafe extern "C" {
|
||||
/// Global mach task port for the running process. Defined in
|
||||
/// `mach/mach_init.h` as `extern mach_port_t mach_task_self_;`.
|
||||
static mach_task_self_: u32;
|
||||
|
||||
/// Releases one send right against `name` within `task`. We only
|
||||
/// ever call this with our own task; the IOSurface keeps its
|
||||
/// retain via the CVPixelBuffer so this just frees our port slot.
|
||||
fn mach_port_deallocate(task: u32, name: u32) -> i32;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::IOSurfaceCache;
|
||||
use objc2_core_foundation::{
|
||||
CFDictionary, CFIndex, CFNumber, CFRetained, CFString, kCFAllocatorDefault,
|
||||
kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks,
|
||||
};
|
||||
use objc2_io_surface::{
|
||||
IOSurfaceRef, kIOSurfaceBytesPerElement, kIOSurfaceBytesPerRow, kIOSurfaceHeight,
|
||||
kIOSurfacePixelFormat, kIOSurfaceWidth,
|
||||
};
|
||||
use std::os::raw::c_void;
|
||||
|
||||
/// Build a CPU-backed IOSurface from scratch, the same way
|
||||
/// surfman's macOS backend does.
|
||||
///
|
||||
/// BGRA8 (four-cc '32BGRA'), width + height + bytes_per_element
|
||||
/// + bytes_per_row live in a Core Foundation properties dictionary.
|
||||
///
|
||||
/// The pointer-casts mirror
|
||||
/// `surfman::platform::macos::system::surface::create_io_surface`.
|
||||
fn build_local_iosurface(width: u32, height: u32) -> Result<CFRetained<IOSurfaceRef>, String> {
|
||||
let pixel_format: i32 = i32::from_be_bytes(*b"BGRA");
|
||||
let bytes_per_element: i32 = 4;
|
||||
let bytes_per_row: i32 = (width as i32) * bytes_per_element;
|
||||
|
||||
let width_num = CFNumber::new_i32(width as i32);
|
||||
let height_num = CFNumber::new_i32(height as i32);
|
||||
let bpe_num = CFNumber::new_i32(bytes_per_element);
|
||||
let bpr_num = CFNumber::new_i32(bytes_per_row);
|
||||
let pf_num = CFNumber::new_i32(pixel_format);
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
let keys: [&CFString; 5] = [
|
||||
kIOSurfaceWidth,
|
||||
kIOSurfaceHeight,
|
||||
kIOSurfaceBytesPerElement,
|
||||
kIOSurfaceBytesPerRow,
|
||||
kIOSurfacePixelFormat,
|
||||
];
|
||||
let values: [&CFNumber; 5] = [&width_num, &height_num, &bpe_num, &bpr_num, &pf_num];
|
||||
let keys_ptr: *mut *const c_void = keys.as_ptr() as *mut *const c_void;
|
||||
let values_ptr: *mut *const c_void = values.as_ptr() as *mut *const c_void;
|
||||
let properties = CFDictionary::new(
|
||||
kCFAllocatorDefault,
|
||||
keys_ptr,
|
||||
values_ptr,
|
||||
keys.len() as CFIndex,
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks,
|
||||
)
|
||||
.ok_or_else(|| "CFDictionaryCreate returned null".to_string())?;
|
||||
IOSurfaceRef::new(&properties)
|
||||
.ok_or_else(|| "IOSurfaceCreate returned null".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_local_iosurface_into_pixel_buffer() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let iosurface = build_local_iosurface(64, 48)?;
|
||||
let mach_port = iosurface.create_mach_port();
|
||||
assert!(mach_port != 0, "IOSurfaceCreateMachPort must yield a real port");
|
||||
let surface_id: u64 = 0xDEAD_BEEFu64;
|
||||
|
||||
cache.import(mach_port, surface_id).map_err(|error| error.to_string())?;
|
||||
|
||||
let pixel_buffer = cache
|
||||
.pixel_buffer_for(surface_id)
|
||||
.ok_or_else(|| "imported pixel buffer was missing".to_string())?;
|
||||
assert_eq!(
|
||||
pixel_buffer.get_width() as u32,
|
||||
64,
|
||||
"CVPixelBuffer width must match the source IOSurface",
|
||||
);
|
||||
assert_eq!(
|
||||
pixel_buffer.get_height() as u32,
|
||||
48,
|
||||
"CVPixelBuffer height must match the source IOSurface",
|
||||
);
|
||||
assert_eq!(cache.cached_surface_count(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_import_with_same_surface_id_is_idempotent() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let iosurface = build_local_iosurface(64, 48)?;
|
||||
let port_a = iosurface.create_mach_port();
|
||||
let port_b = iosurface.create_mach_port();
|
||||
assert!(port_a != 0 && port_b != 0 && port_a != port_b);
|
||||
|
||||
cache.import(port_a, 0xAAAA_AAAA).map_err(|error| error.to_string())?;
|
||||
// Same surface_id → defensive dedup path; port_b is deallocated
|
||||
// without minting a duplicate CVPixelBuffer.
|
||||
cache.import(port_b, 0xAAAA_AAAA).map_err(|error| error.to_string())?;
|
||||
assert_eq!(cache.cached_surface_count(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_surface_id_with_changed_dimensions_replaces_pixel_buffer() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let initial = build_local_iosurface(64, 48)?;
|
||||
let resized = build_local_iosurface(96, 72)?;
|
||||
let surface_id = 0xBBBB_BBBB;
|
||||
|
||||
cache.import(initial.create_mach_port(), surface_id).map_err(|error| error.to_string())?;
|
||||
cache.import(resized.create_mach_port(), surface_id).map_err(|error| error.to_string())?;
|
||||
|
||||
let pixel_buffer = cache
|
||||
.pixel_buffer_for(surface_id)
|
||||
.ok_or_else(|| "resized pixel buffer was missing".to_string())?;
|
||||
assert_eq!(pixel_buffer.get_width() as u32, 96);
|
||||
assert_eq!(pixel_buffer.get_height() as u32, 72);
|
||||
assert_eq!(cache.cached_surface_count(), 1);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
pub mod download_checksums;
|
||||
pub mod download_files;
|
||||
pub mod http_downloads;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) mod iosurface_mach;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) mod iosurface_metal;
|
||||
pub mod plugin_package_store;
|
||||
pub mod plugin_packages;
|
||||
pub mod plugin_signatures;
|
||||
pub mod servo_live;
|
||||
pub(crate) mod servo_profile_data;
|
||||
mod servo_sidecar_command;
|
||||
|
||||
pub(crate) use servo_profile_data::ProfileDataMode;
|
||||
|
||||
|
||||
@@ -1,286 +1,300 @@
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
path::PathBuf,
|
||||
process::{Child, ChildStdin, ChildStdout, Stdio},
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
use ely_domain::{
|
||||
ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, TabId, UrlText, WebViewId,
|
||||
};
|
||||
use ely_servo_host::{
|
||||
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest,
|
||||
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest,
|
||||
ScrollRequest, ServoHost, ServoSurfaceSize, SoftwareServoHost,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[path = "servo_live_iosurface_importer.rs"]
|
||||
mod iosurface_importer;
|
||||
#[path = "servo_live_types.rs"]
|
||||
mod types;
|
||||
/// Environment variable that lets the user pick the rendering context
|
||||
/// kind used by the spawned sidecar. Accepted values: `software`
|
||||
/// and `hardware`. macOS defaults to the hardware path and receives
|
||||
/// IOSurface mach send rights over a side Mach channel.
|
||||
#[path = "servo_live_wire.rs"]
|
||||
mod wire;
|
||||
|
||||
pub(crate) use types::{
|
||||
ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission,
|
||||
};
|
||||
|
||||
use super::servo_sidecar_command::{
|
||||
SidecarRenderingContext, default_sidecar_command, rendering_context_from_env,
|
||||
};
|
||||
use wire::{
|
||||
LiveRequest, LiveResponse, LiveSurfaceHandle, log_frame_perf, log_iosurface_current,
|
||||
log_iosurface_handle,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::iosurface_metal::IOSurfaceCache;
|
||||
#[cfg(target_os = "macos")]
|
||||
use iosurface_importer::{IOSurfaceImportResult, IOSurfaceImportWorker};
|
||||
|
||||
pub(crate) struct ServoLiveClient {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
/// Cache of imported `CVPixelBuffer`s keyed by surface_id. Built
|
||||
/// lazily on the first `surface_handle` the sidecar publishes —
|
||||
/// software-path tabs never trigger construction.
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_cache: IOSurfaceCache,
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_importer: Option<IOSurfaceImportWorker>,
|
||||
host: SoftwareServoHost,
|
||||
sessions: BTreeMap<String, DirectWebViewSession>,
|
||||
}
|
||||
|
||||
impl ServoLiveClient {
|
||||
pub fn new(profile_data_dir: PathBuf) -> Result<Self, ServoLiveError> {
|
||||
let command_target = default_sidecar_command()?;
|
||||
if let Some(path) = command_target.missing_binary_path() {
|
||||
return Err(ServoLiveError::SidecarBinaryUnavailable { path: path.to_path_buf() });
|
||||
}
|
||||
|
||||
let rendering_context = rendering_context_from_env();
|
||||
let mut command = command_target.command();
|
||||
command.arg("live").arg("--profile-data-dir").arg(profile_data_dir);
|
||||
command.arg("--rendering-context").arg(rendering_context.cli_arg());
|
||||
#[cfg(target_os = "macos")]
|
||||
let iosurface_importer = if rendering_context == SidecarRenderingContext::Hardware {
|
||||
let receiver = super::iosurface_mach::IOSurfaceMachReceiver::new()?;
|
||||
command.arg("--iosurface-mach-service").arg(receiver.service_name());
|
||||
Some(
|
||||
IOSurfaceImportWorker::new(receiver)
|
||||
.map_err(ServoLiveError::IOSurfaceImportWorker)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(ServoLiveError::Command)?;
|
||||
let stdin = child.stdin.take().ok_or(ServoLiveError::PipeUnavailable { name: "stdin" })?;
|
||||
let stdout =
|
||||
child.stdout.take().ok_or(ServoLiveError::PipeUnavailable { name: "stdout" })?;
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout: BufReader::new(stdout),
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_cache: IOSurfaceCache::new(),
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_importer,
|
||||
})
|
||||
let host = SoftwareServoHost::new_with_config_dir(
|
||||
ServoSurfaceSize::new(1, 1),
|
||||
Some(profile_data_dir),
|
||||
)?;
|
||||
Ok(Self { host, sessions: BTreeMap::new() })
|
||||
}
|
||||
|
||||
pub fn ensure(
|
||||
&mut self,
|
||||
request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
||||
#[cfg(target_os = "macos")]
|
||||
self.drain_iosurface_imports()?;
|
||||
let ready_surface_ids = self.ready_surface_ids();
|
||||
self.request(LiveRequest::Ensure {
|
||||
tab_id: request.tab_id,
|
||||
profile_id: request.profile_id,
|
||||
url: request.url,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
page_zoom_percent: request.page_zoom_percent,
|
||||
device_pixel_ratio: request.device_pixel_ratio,
|
||||
scroll_delta_x: request.scroll_delta_x,
|
||||
scroll_delta_y: request.scroll_delta_y,
|
||||
scroll_point_x: request.scroll_point_x,
|
||||
scroll_point_y: request.scroll_point_y,
|
||||
click_x: request.click_x,
|
||||
click_y: request.click_y,
|
||||
hover_x: request.hover_x,
|
||||
hover_y: request.hover_y,
|
||||
typed_text: request.typed_text,
|
||||
site_permissions: request.site_permissions,
|
||||
ready_surface_ids,
|
||||
})
|
||||
let tab_id = TabId::parse(request.tab_id.clone())?;
|
||||
let profile_id = ProfileId::parse(request.profile_id.clone())?;
|
||||
let requested_url = UrlText::parse(request.url.clone())?;
|
||||
let webview_id = self.ensure_webview(&request, &tab_id, &profile_id)?;
|
||||
|
||||
self.apply_viewport(&request, &webview_id)?;
|
||||
self.apply_permissions(&request, &webview_id, &profile_id)?;
|
||||
self.apply_navigation(&request, &webview_id, tab_id, requested_url)?;
|
||||
self.apply_input(&request, &webview_id)?;
|
||||
self.host.tick();
|
||||
if !self.session_uses_native_surface(&request.tab_id) {
|
||||
return Err(ServoLiveError::NativeSurfaceUnavailable);
|
||||
}
|
||||
self.host.paint_without_readback_with_completion(&webview_id, false)?;
|
||||
|
||||
let frame = self.frame_from_session(&request.tab_id, &webview_id)?;
|
||||
Ok(Some(frame))
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
||||
#[cfg(target_os = "macos")]
|
||||
self.drain_iosurface_imports()?;
|
||||
let ready_surface_ids = self.ready_surface_ids();
|
||||
self.request(LiveRequest::Poll { tab_id, ready_surface_ids })
|
||||
let Some(session) = self.sessions.get(&tab_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let webview_id = session.webview_id.clone();
|
||||
let uses_native_surface = session.native_surface_id.is_some();
|
||||
|
||||
self.host.tick();
|
||||
if !self.host.snapshot(&webview_id)?.has_pending_frame() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !uses_native_surface {
|
||||
return Err(ServoLiveError::NativeSurfaceUnavailable);
|
||||
}
|
||||
self.host.paint_without_readback_with_completion(&webview_id, false)?;
|
||||
self.frame_from_session(&tab_id, &webview_id).map(Some)
|
||||
}
|
||||
|
||||
pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> {
|
||||
self.request(LiveRequest::Close { tab_id }).map(|_| ())
|
||||
}
|
||||
|
||||
fn request(&mut self, request: LiveRequest) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
||||
serde_json::to_writer(&mut self.stdin, &request)?;
|
||||
self.stdin.write_all(b"\n").map_err(ServoLiveError::Command)?;
|
||||
self.stdin.flush().map_err(ServoLiveError::Command)?;
|
||||
|
||||
let mut line = String::new();
|
||||
let bytes = self.stdout.read_line(&mut line).map_err(ServoLiveError::Command)?;
|
||||
if bytes == 0 {
|
||||
return Err(ServoLiveError::SidecarExited);
|
||||
}
|
||||
|
||||
let response: LiveResponse = serde_json::from_str(&line)?;
|
||||
if let Some(error) = response.error {
|
||||
return Err(ServoLiveError::SidecarFailed { message: error });
|
||||
}
|
||||
let surface_handle = response.surface_handle;
|
||||
let current_surface_id = response.current_surface_id;
|
||||
|
||||
if let Some(perf) = response.perf.as_ref() {
|
||||
log_frame_perf(perf);
|
||||
}
|
||||
|
||||
if let Some(handle) = surface_handle.as_ref() {
|
||||
log_iosurface_handle(handle);
|
||||
}
|
||||
if let Some(surface_id) = current_surface_id {
|
||||
log_iosurface_current(surface_id);
|
||||
}
|
||||
|
||||
let Some(report) = response.frame else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Sanity bound the byte count advertised by the sidecar
|
||||
// header so a buggy or hostile sidecar can't park us on
|
||||
// `read_exact` for an arbitrarily-sized buffer. The honest
|
||||
// upper limit is `width * height * 4` (RGBA8); `0` is the
|
||||
// explicit "hardware path active, sample the IOSurface"
|
||||
// signal; any other byte count is a protocol violation.
|
||||
let pixel_byte_count =
|
||||
(report.width as u64).saturating_mul(report.height as u64).saturating_mul(4);
|
||||
let advertised = report.rgba_byte_count as u64;
|
||||
if advertised != 0 && advertised != pixel_byte_count {
|
||||
return Err(ServoLiveError::FrameBudgetExceeded {
|
||||
advertised: report.rgba_byte_count,
|
||||
pixel_budget: pixel_byte_count,
|
||||
width: report.width,
|
||||
height: report.height,
|
||||
});
|
||||
}
|
||||
|
||||
// Raw frame bytes follow the JSON header on the same pipe for
|
||||
// software frames. `read_exact` drains BufReader's buffer first
|
||||
// (the line read never crosses the `\n` boundary) and then
|
||||
// pulls the rest straight from the child's stdout.
|
||||
let mut rgba_bytes = vec![0u8; report.rgba_byte_count];
|
||||
if report.rgba_byte_count > 0 {
|
||||
self.stdout.read_exact(&mut rgba_bytes).map_err(ServoLiveError::FrameRead)?;
|
||||
}
|
||||
|
||||
let has_software_payload = report.rgba_byte_count > 0;
|
||||
let mut frame = ServoLiveFrame::from_parts(report, rgba_bytes);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(handle) = surface_handle.as_ref() {
|
||||
self.queue_iosurface_handle(*handle)?;
|
||||
self.drain_iosurface_imports()?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(surface_id) = current_surface_id {
|
||||
let pixel_buffer = self.iosurface_cache.pixel_buffer_for(surface_id);
|
||||
if pixel_buffer.is_none() && !has_software_payload {
|
||||
return Ok(None);
|
||||
}
|
||||
frame.set_pixel_buffer(pixel_buffer);
|
||||
}
|
||||
|
||||
Ok(Some(frame))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
impl ServoLiveClient {
|
||||
fn ready_surface_ids(&self) -> Vec<u64> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl ServoLiveClient {
|
||||
fn ready_surface_ids(&self) -> Vec<u64> {
|
||||
self.iosurface_cache.surface_ids()
|
||||
}
|
||||
|
||||
fn queue_iosurface_handle(&mut self, handle: LiveSurfaceHandle) -> Result<(), ServoLiveError> {
|
||||
let Some(importer) = self.iosurface_importer.as_ref() else {
|
||||
return Err(ServoLiveError::IOSurfaceImportFailed {
|
||||
surface_id: handle.surface_id,
|
||||
mach_port_name: handle.mach_port_name,
|
||||
message: "IOSurface import worker is unavailable".to_string(),
|
||||
});
|
||||
};
|
||||
importer.submit(handle).map_err(|failure| ServoLiveError::IOSurfaceImportFailed {
|
||||
surface_id: failure.surface_id,
|
||||
mach_port_name: failure.mach_port_name,
|
||||
message: failure.message,
|
||||
})
|
||||
}
|
||||
|
||||
fn drain_iosurface_imports(&mut self) -> Result<(), ServoLiveError> {
|
||||
let Some(importer) = self.iosurface_importer.as_ref() else {
|
||||
let Some(session) = self.sessions.remove(&tab_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
for result in importer.drain() {
|
||||
match result {
|
||||
IOSurfaceImportResult::Imported(imported) => {
|
||||
self.iosurface_cache
|
||||
.insert_pixel_buffer(imported.surface_id, imported.pixel_buffer);
|
||||
tracing::info!(
|
||||
target: "ely::servo::iosurface",
|
||||
surface_id = imported.surface_id,
|
||||
width = imported.width,
|
||||
height = imported.height,
|
||||
"imported IOSurface into CVPixelBuffer cache",
|
||||
);
|
||||
}
|
||||
IOSurfaceImportResult::Failed(failure) => {
|
||||
tracing::warn!(
|
||||
target: "ely::servo::iosurface",
|
||||
surface_id = failure.surface_id,
|
||||
width = failure.width,
|
||||
height = failure.height,
|
||||
mach_port_name = failure.mach_port_name,
|
||||
message = %failure.message,
|
||||
"IOSurface import worker failed",
|
||||
);
|
||||
return Err(ServoLiveError::IOSurfaceImportFailed {
|
||||
surface_id: failure.surface_id,
|
||||
mach_port_name: failure.mach_port_name,
|
||||
message: failure.message,
|
||||
});
|
||||
}
|
||||
self.host.close_webview(&session.webview_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_webview(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
tab_id: &TabId,
|
||||
profile_id: &ProfileId,
|
||||
) -> Result<WebViewId, ServoLiveError> {
|
||||
if self
|
||||
.sessions
|
||||
.get(&request.tab_id)
|
||||
.is_some_and(|session| session.profile_id != *profile_id)
|
||||
&& let Some(session) = self.sessions.remove(&request.tab_id)
|
||||
{
|
||||
self.host.close_webview(&session.webview_id);
|
||||
}
|
||||
|
||||
let native_surface_id =
|
||||
request.native_surface.as_ref().map(gpui::NativeSurfaceHandle::identity);
|
||||
if self
|
||||
.sessions
|
||||
.get(&request.tab_id)
|
||||
.is_some_and(|session| session.native_surface_id != native_surface_id)
|
||||
&& let Some(session) = self.sessions.remove(&request.tab_id)
|
||||
{
|
||||
self.host.close_webview(&session.webview_id);
|
||||
}
|
||||
|
||||
if let Some(session) = self.sessions.get(&request.tab_id) {
|
||||
return Ok(session.webview_id.clone());
|
||||
}
|
||||
|
||||
let surface_size = ServoSurfaceSize::new(request.width, request.height);
|
||||
let webview_id = match request.native_surface.as_ref() {
|
||||
Some(native_surface) => self.host.create_webview_with_native_surface(
|
||||
tab_id.clone(),
|
||||
profile_id.clone(),
|
||||
surface_size,
|
||||
native_surface,
|
||||
)?,
|
||||
None => self.host.create_webview_with_size(
|
||||
tab_id.clone(),
|
||||
profile_id.clone(),
|
||||
surface_size,
|
||||
)?,
|
||||
};
|
||||
self.sessions.insert(
|
||||
request.tab_id.clone(),
|
||||
DirectWebViewSession {
|
||||
webview_id: webview_id.clone(),
|
||||
profile_id: profile_id.clone(),
|
||||
requested_url: None,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
page_zoom_percent: request.page_zoom_percent,
|
||||
device_pixel_ratio: request.device_pixel_ratio,
|
||||
native_surface_id,
|
||||
},
|
||||
);
|
||||
Ok(webview_id)
|
||||
}
|
||||
|
||||
fn apply_viewport(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
webview_id: &WebViewId,
|
||||
) -> Result<(), ServoLiveError> {
|
||||
let Some(session) = self.sessions.get_mut(&request.tab_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if session.width != request.width || session.height != request.height {
|
||||
self.host.resize(ResizeRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
})?;
|
||||
session.width = request.width;
|
||||
session.height = request.height;
|
||||
}
|
||||
|
||||
if session.device_pixel_ratio != request.device_pixel_ratio {
|
||||
self.host.set_hidpi_scale(HidpiScaleRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
scale_factor: request.device_pixel_ratio,
|
||||
})?;
|
||||
session.device_pixel_ratio = request.device_pixel_ratio;
|
||||
}
|
||||
|
||||
if session.page_zoom_percent != request.page_zoom_percent {
|
||||
self.host.set_page_zoom(PageZoomRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
zoom_factor: f32::from(request.page_zoom_percent) / 100.0,
|
||||
})?;
|
||||
session.page_zoom_percent = request.page_zoom_percent;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_permissions(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
webview_id: &WebViewId,
|
||||
profile_id: &ProfileId,
|
||||
) -> Result<(), ServoLiveError> {
|
||||
for permission in &request.site_permissions {
|
||||
let origin = SiteOrigin::parse(permission.origin.clone())?;
|
||||
let feature = SitePermissionFeature::parse(permission.feature.as_str())?;
|
||||
let decision = SitePermissionDecision::parse(permission.decision.as_str())?;
|
||||
self.host.set_permission(
|
||||
PermissionRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
profile_id: profile_id.clone(),
|
||||
origin,
|
||||
feature,
|
||||
},
|
||||
PermissionDecision::from(decision),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_navigation(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
webview_id: &WebViewId,
|
||||
tab_id: TabId,
|
||||
requested_url: UrlText,
|
||||
) -> Result<(), ServoLiveError> {
|
||||
let should_navigate = self
|
||||
.sessions
|
||||
.get(&request.tab_id)
|
||||
.and_then(|session| session.requested_url.as_deref())
|
||||
.is_none_or(|current| current != requested_url.as_str());
|
||||
if should_navigate {
|
||||
self.host.navigate(NavigationRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
tab_id,
|
||||
url: requested_url.clone(),
|
||||
})?;
|
||||
if let Some(session) = self.sessions.get_mut(&request.tab_id) {
|
||||
session.requested_url = Some(requested_url.as_str().to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServoLiveClient {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
fn apply_input(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
webview_id: &WebViewId,
|
||||
) -> Result<(), ServoLiveError> {
|
||||
if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 {
|
||||
let point_x = request.scroll_point_x.ok_or(ServoLiveError::MissingScrollPoint)?;
|
||||
let point_y = request.scroll_point_y.ok_or(ServoLiveError::MissingScrollPoint)?;
|
||||
self.host.scroll(ScrollRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
delta_x: request.scroll_delta_x,
|
||||
delta_y: request.scroll_delta_y,
|
||||
point_x,
|
||||
point_y,
|
||||
})?;
|
||||
}
|
||||
|
||||
if let (Some(x), Some(y)) = (request.hover_x, request.hover_y) {
|
||||
self.host.hover(MouseHoverRequest { webview_id: webview_id.clone(), x, y })?;
|
||||
}
|
||||
|
||||
if let (Some(x), Some(y)) = (request.click_x, request.click_y) {
|
||||
self.host.click(MouseClickRequest { webview_id: webview_id.clone(), x, y })?;
|
||||
}
|
||||
|
||||
if let Some(text) = request.typed_text.as_ref() {
|
||||
self.host.type_text(KeyboardTextRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
text: text.clone(),
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn frame_from_session(
|
||||
&self,
|
||||
tab_id: &str,
|
||||
webview_id: &WebViewId,
|
||||
) -> Result<ServoLiveFrame, ServoLiveError> {
|
||||
let Some(session) = self.sessions.get(tab_id) else {
|
||||
return Err(ServoLiveError::Host(ely_servo_host::ServoHostError::WebViewNotFound {
|
||||
id: webview_id.clone(),
|
||||
}));
|
||||
};
|
||||
if session.native_surface_id.is_some() {
|
||||
let snapshot = self.host.snapshot(webview_id)?;
|
||||
return Ok(ServoLiveFrame::from_presented(
|
||||
snapshot,
|
||||
session.width,
|
||||
session.height,
|
||||
session.device_pixel_ratio,
|
||||
));
|
||||
}
|
||||
Err(ServoLiveError::NativeSurfaceUnavailable)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectWebViewSession {
|
||||
webview_id: WebViewId,
|
||||
profile_id: ProfileId,
|
||||
requested_url: Option<String>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
page_zoom_percent: u16,
|
||||
device_pixel_ratio: f32,
|
||||
native_surface_id: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::{
|
||||
io,
|
||||
sync::mpsc,
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use core_video::pixel_buffer::CVPixelBuffer;
|
||||
|
||||
use crate::services::{
|
||||
iosurface_mach::IOSurfaceMachReceiver, iosurface_metal::import_pixel_buffer_from_mach_port,
|
||||
};
|
||||
|
||||
use super::wire::LiveSurfaceHandle;
|
||||
|
||||
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
pub(super) struct IOSurfaceImportWorker {
|
||||
request_tx: Option<mpsc::Sender<LiveSurfaceHandle>>,
|
||||
result_rx: mpsc::Receiver<IOSurfaceImportResult>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl IOSurfaceImportWorker {
|
||||
pub(super) fn new(receiver: IOSurfaceMachReceiver) -> Result<Self, io::Error> {
|
||||
let (request_tx, request_rx) = mpsc::channel();
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
let thread = thread::Builder::new()
|
||||
.name("ely-iosurface-import".to_string())
|
||||
.spawn(move || run_import_worker(receiver, request_rx, result_tx))?;
|
||||
|
||||
Ok(Self { request_tx: Some(request_tx), result_rx, thread: Some(thread) })
|
||||
}
|
||||
|
||||
pub(super) fn submit(&self, handle: LiveSurfaceHandle) -> Result<(), IOSurfaceImportFailure> {
|
||||
let Some(request_tx) = self.request_tx.as_ref() else {
|
||||
return Err(IOSurfaceImportFailure::worker_stopped(handle));
|
||||
};
|
||||
request_tx.send(handle).map_err(|error| IOSurfaceImportFailure::worker_stopped(error.0))
|
||||
}
|
||||
|
||||
pub(super) fn drain(&self) -> Vec<IOSurfaceImportResult> {
|
||||
let mut results = Vec::new();
|
||||
while let Ok(result) = self.result_rx.try_recv() {
|
||||
results.push(result);
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IOSurfaceImportWorker {
|
||||
fn drop(&mut self) {
|
||||
self.request_tx.take();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum IOSurfaceImportResult {
|
||||
Imported(ImportedIOSurface),
|
||||
Failed(IOSurfaceImportFailure),
|
||||
}
|
||||
|
||||
pub(super) struct ImportedIOSurface {
|
||||
pub(super) surface_id: u64,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
pub(super) pixel_buffer: CVPixelBuffer,
|
||||
}
|
||||
|
||||
// SAFETY: CVPixelBuffer is a CoreFoundation object with atomic
|
||||
// retain/release semantics. This wrapper crosses from the importer
|
||||
// thread to the live worker thread; GPUI presentation already receives
|
||||
// the same handle through ServoLiveFrame's Send contract.
|
||||
#[expect(unsafe_code)]
|
||||
unsafe impl Send for ImportedIOSurface {}
|
||||
|
||||
pub(super) struct IOSurfaceImportFailure {
|
||||
pub(super) surface_id: u64,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
pub(super) mach_port_name: u32,
|
||||
pub(super) message: String,
|
||||
}
|
||||
|
||||
impl IOSurfaceImportFailure {
|
||||
fn worker_stopped(handle: LiveSurfaceHandle) -> Self {
|
||||
Self {
|
||||
surface_id: handle.surface_id,
|
||||
width: handle.width,
|
||||
height: handle.height,
|
||||
mach_port_name: handle.mach_port_name,
|
||||
message: "IOSurface import worker stopped".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_import_worker(
|
||||
mut receiver: IOSurfaceMachReceiver,
|
||||
request_rx: mpsc::Receiver<LiveSurfaceHandle>,
|
||||
result_tx: mpsc::Sender<IOSurfaceImportResult>,
|
||||
) {
|
||||
while let Ok(handle) = request_rx.recv() {
|
||||
let result = import_surface_handle(&mut receiver, handle);
|
||||
if result_tx.send(result).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn import_surface_handle(
|
||||
receiver: &mut IOSurfaceMachReceiver,
|
||||
handle: LiveSurfaceHandle,
|
||||
) -> IOSurfaceImportResult {
|
||||
let mach_port_name = match receiver.receive_port_for_surface(handle.surface_id, RECEIVE_TIMEOUT)
|
||||
{
|
||||
Ok(mach_port_name) => mach_port_name,
|
||||
Err(error) => {
|
||||
return IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
||||
surface_id: handle.surface_id,
|
||||
width: handle.width,
|
||||
height: handle.height,
|
||||
mach_port_name: handle.mach_port_name,
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
match import_pixel_buffer_from_mach_port(mach_port_name) {
|
||||
Ok(pixel_buffer) => IOSurfaceImportResult::Imported(ImportedIOSurface {
|
||||
surface_id: handle.surface_id,
|
||||
width: handle.width,
|
||||
height: handle.height,
|
||||
pixel_buffer,
|
||||
}),
|
||||
Err(error) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
||||
surface_id: handle.surface_id,
|
||||
width: handle.width,
|
||||
height: handle.height,
|
||||
mach_port_name,
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
use std::{io, path::PathBuf};
|
||||
|
||||
use ely_domain::SitePermissionDecision;
|
||||
use ely_servo_host::{ServoHostError, WebViewSnapshot, WebViewState};
|
||||
use gpui::NativeSurfaceHandle;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::wire::LiveFrameReport;
|
||||
use crate::services::servo_sidecar_command::SidecarCommandError;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::services::iosurface_mach::IOSurfaceMachError;
|
||||
#[cfg(target_os = "macos")]
|
||||
use core_video::pixel_buffer::CVPixelBuffer;
|
||||
|
||||
pub(crate) struct ServoLiveEnsureRequest {
|
||||
pub(crate) tab_id: String,
|
||||
pub(crate) profile_id: String,
|
||||
@@ -20,10 +12,9 @@ pub(crate) struct ServoLiveEnsureRequest {
|
||||
pub(crate) height: u32,
|
||||
pub(crate) page_zoom_percent: u16,
|
||||
/// Display scale factor (1.0 standard, 2.0 Retina). Servo's
|
||||
/// WebView lays out CSS pixels = device pixels / hidpi factor;
|
||||
/// without this, a Retina viewport gets desktop-CSS-pixel layout
|
||||
/// and every visible element renders at half its expected size.
|
||||
/// WebView lays out CSS pixels = device pixels / hidpi factor.
|
||||
pub(crate) device_pixel_ratio: f32,
|
||||
pub(crate) native_surface: Option<NativeSurfaceHandle>,
|
||||
pub(crate) scroll_delta_x: i32,
|
||||
pub(crate) scroll_delta_y: i32,
|
||||
pub(crate) scroll_point_x: Option<u32>,
|
||||
@@ -68,54 +59,37 @@ pub(crate) struct ServoLiveFrame {
|
||||
content_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: u64,
|
||||
rgba_bytes: Vec<u8>,
|
||||
#[cfg(target_os = "macos")]
|
||||
pixel_buffer: Option<CVPixelBuffer>,
|
||||
rgba_bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
// SAFETY: CVPixelBuffer wraps CVPixelBufferRef, a CoreFoundation type
|
||||
// Apple documents as safe to share across threads. The Rust core-video
|
||||
// crate does not mark it Send, so the worker thread needs this opt-in
|
||||
// to ship hardware frames back to the UI thread via mpsc::Sender.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[expect(unsafe_code)]
|
||||
unsafe impl Send for ServoLiveFrame {}
|
||||
|
||||
impl ServoLiveFrame {
|
||||
pub(super) fn from_parts(report: LiveFrameReport, rgba_bytes: Vec<u8>) -> Self {
|
||||
let (css_viewport_width, css_viewport_height) = css_viewport_size_from_report(&report);
|
||||
pub(super) fn from_presented(
|
||||
snapshot: WebViewSnapshot,
|
||||
width: u32,
|
||||
height: u32,
|
||||
device_pixel_ratio: f32,
|
||||
) -> Self {
|
||||
let (css_viewport_width, css_viewport_height) =
|
||||
css_viewport_size(width, height, device_pixel_ratio);
|
||||
Self {
|
||||
loaded_url: report.loaded_url,
|
||||
title: report.title,
|
||||
render_state: report.state,
|
||||
width: report.width,
|
||||
height: report.height,
|
||||
device_pixel_ratio: report.device_pixel_ratio,
|
||||
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: report.non_white_pixel_count,
|
||||
non_white_pixel_count: 1,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
content_pixel_count: report.content_pixel_count,
|
||||
content_pixel_count: 1,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: report.sample_hash,
|
||||
rgba_bytes,
|
||||
#[cfg(target_os = "macos")]
|
||||
pixel_buffer: None,
|
||||
sample_hash: 0,
|
||||
rgba_bytes: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn set_pixel_buffer(&mut self, pixel_buffer: Option<CVPixelBuffer>) {
|
||||
self.pixel_buffer = pixel_buffer;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[must_use]
|
||||
pub fn pixel_buffer(&self) -> Option<&CVPixelBuffer> {
|
||||
self.pixel_buffer.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn loaded_url(&self) -> Option<&str> {
|
||||
self.loaded_url.as_deref()
|
||||
@@ -175,7 +149,7 @@ impl ServoLiveFrame {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_rgba_bytes(self) -> Vec<u8> {
|
||||
pub fn into_rgba_bytes(self) -> Option<Vec<u8>> {
|
||||
self.rgba_bytes
|
||||
}
|
||||
|
||||
@@ -196,113 +170,50 @@ impl ServoLiveFrame {
|
||||
content_pixel_count: 0,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: 0,
|
||||
rgba_bytes,
|
||||
#[cfg(target_os = "macos")]
|
||||
pixel_buffer: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
pub(crate) fn for_test_with_pixel_buffer(
|
||||
width: u32,
|
||||
height: u32,
|
||||
pixel_buffer: CVPixelBuffer,
|
||||
) -> Self {
|
||||
Self {
|
||||
loaded_url: Some("https://example.com/".to_string()),
|
||||
title: Some("Example".to_string()),
|
||||
render_state: "complete".to_string(),
|
||||
width,
|
||||
height,
|
||||
device_pixel_ratio: 1.0,
|
||||
css_viewport_width: width,
|
||||
css_viewport_height: height,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: 0,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
content_pixel_count: 0,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: 0,
|
||||
rgba_bytes: Vec::new(),
|
||||
pixel_buffer: Some(pixel_buffer),
|
||||
rgba_bytes: Some(rgba_bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn css_viewport_size_from_report(report: &LiveFrameReport) -> (u32, u32) {
|
||||
let dpr = if report.device_pixel_ratio.is_finite() && report.device_pixel_ratio > 0.0 {
|
||||
report.device_pixel_ratio
|
||||
fn render_state_label(state: &WebViewState) -> &'static str {
|
||||
match state {
|
||||
WebViewState::Created => "created",
|
||||
WebViewState::Loading => "loading",
|
||||
WebViewState::Complete => "complete",
|
||||
WebViewState::Sleeping => "sleeping",
|
||||
WebViewState::Crashed => "crashed",
|
||||
}
|
||||
}
|
||||
|
||||
fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) {
|
||||
let scale = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 {
|
||||
device_pixel_ratio
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let fallback_width = ((report.width as f32) / dpr).round().max(1.0) as u32;
|
||||
let fallback_height = ((report.height as f32) / dpr).round().max(1.0) as u32;
|
||||
(
|
||||
if report.css_viewport_width > 0 { report.css_viewport_width } else { fallback_width },
|
||||
if report.css_viewport_height > 0 { report.css_viewport_height } else { fallback_height },
|
||||
((width as f32) / scale).round().max(1.0) as u32,
|
||||
((height as f32) / scale).round().max(1.0) as u32,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum ServoLiveError {
|
||||
#[error("servo sidecar binary is unavailable at {path}")]
|
||||
SidecarBinaryUnavailable { path: PathBuf },
|
||||
#[error("servo native surface is unavailable")]
|
||||
NativeSurfaceUnavailable,
|
||||
|
||||
#[error("failed to run servo live sidecar: {0}")]
|
||||
Command(#[source] io::Error),
|
||||
|
||||
#[error("servo live sidecar pipe is unavailable: {name}")]
|
||||
PipeUnavailable { name: &'static str },
|
||||
|
||||
#[error("servo live sidecar exited")]
|
||||
SidecarExited,
|
||||
|
||||
#[error("servo live sidecar failed: {message}")]
|
||||
SidecarFailed { message: String },
|
||||
|
||||
#[error("failed to read servo live frame bytes: {0}")]
|
||||
FrameRead(#[source] io::Error),
|
||||
|
||||
#[error(
|
||||
"servo live sidecar advertised {advertised} frame bytes which exceeds \
|
||||
the {width}x{height} pixel budget ({pixel_budget} bytes)"
|
||||
)]
|
||||
FrameBudgetExceeded { advertised: usize, pixel_budget: u64, width: u32, height: u32 },
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error(
|
||||
"servo live IOSurface import failed for surface {surface_id:#x} \
|
||||
mach port 0x{mach_port_name:x}: {message}"
|
||||
)]
|
||||
IOSurfaceImportFailed { surface_id: u64, mach_port_name: u32, message: String },
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("failed to spawn servo live IOSurface importer: {0}")]
|
||||
IOSurfaceImportWorker(#[source] io::Error),
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error(transparent)]
|
||||
IOSurfaceMach(#[from] IOSurfaceMachError),
|
||||
#[error("servo scroll input is missing a viewport point")]
|
||||
MissingScrollPoint,
|
||||
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
Domain(#[from] ely_domain::DomainError),
|
||||
|
||||
#[error(transparent)]
|
||||
SidecarCommand(#[from] SidecarCommandError),
|
||||
Host(#[from] ServoHostError),
|
||||
}
|
||||
|
||||
impl ServoLiveError {
|
||||
pub(crate) fn is_sidecar_process_unusable(&self) -> bool {
|
||||
match self {
|
||||
Self::SidecarExited => true,
|
||||
Self::Command(error) | Self::FrameRead(error) => matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::BrokenPipe
|
||||
| io::ErrorKind::ConnectionAborted
|
||||
| io::ErrorKind::ConnectionReset
|
||||
| io::ErrorKind::UnexpectedEof
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
pub(crate) fn is_runtime_unavailable(&self) -> bool {
|
||||
matches!(self, Self::Host(ServoHostError::RuntimeAlreadyStarted))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ServoLiveSitePermission;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(super) enum LiveRequest {
|
||||
Ensure {
|
||||
tab_id: String,
|
||||
profile_id: String,
|
||||
url: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
page_zoom_percent: u16,
|
||||
device_pixel_ratio: f32,
|
||||
scroll_delta_x: i32,
|
||||
scroll_delta_y: i32,
|
||||
scroll_point_x: Option<u32>,
|
||||
scroll_point_y: Option<u32>,
|
||||
click_x: Option<u32>,
|
||||
click_y: Option<u32>,
|
||||
hover_x: Option<u32>,
|
||||
hover_y: Option<u32>,
|
||||
typed_text: Option<String>,
|
||||
site_permissions: Vec<ServoLiveSitePermission>,
|
||||
ready_surface_ids: Vec<u64>,
|
||||
},
|
||||
Poll {
|
||||
tab_id: String,
|
||||
ready_surface_ids: Vec<u64>,
|
||||
},
|
||||
Close {
|
||||
tab_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LiveResponse {
|
||||
pub(super) error: Option<String>,
|
||||
pub(super) frame: Option<LiveFrameReport>,
|
||||
#[serde(default)]
|
||||
pub(super) perf: Option<LiveFramePerfSummary>,
|
||||
/// Hardware path only: present on the first frame after a new
|
||||
/// IOSurface is bound (initial paint, resize, surfman swap chain
|
||||
/// rotation). T10.4 will turn this into an imported Metal texture;
|
||||
/// for now we log it on the `ely::servo::iosurface` target so the
|
||||
/// pipeline is observable end-to-end without yet wiring it into
|
||||
/// the renderer.
|
||||
#[serde(default)]
|
||||
pub(super) surface_handle: Option<LiveSurfaceHandle>,
|
||||
/// Hardware path only: which previously-imported IOSurface to
|
||||
/// sample this frame. surfman's attached swap chain rotates the
|
||||
/// bound surface, so this id alternates between the values the
|
||||
/// receiver has already imported via `surface_handle`.
|
||||
#[serde(default)]
|
||||
pub(super) current_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// Wire mirror of `ely_servo_host::IOSurfaceHandle`. Duplicated rather
|
||||
/// than imported because `ely_app` only talks to the sidecar via
|
||||
/// stdin/stdout JSON — it has no crate dependency on `ely_servo_host`
|
||||
/// and adding one just to share a four-field struct would pull the
|
||||
/// Servo dep tree into the renderer process.
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
pub(super) struct LiveSurfaceHandle {
|
||||
pub(super) mach_port_name: u32,
|
||||
pub(super) surface_id: u64,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
}
|
||||
|
||||
/// Aggregated frame-stage timings rolled up every N frames by the
|
||||
/// sidecar. We accept anything matching the wire shape and let the
|
||||
/// `tracing` event echo the percentiles verbatim — the sidecar is
|
||||
/// the source of truth for histogram boundaries.
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LiveFramePerfSummary {
|
||||
window: u32,
|
||||
context: String,
|
||||
paint_p50_us: u64,
|
||||
paint_p95_us: u64,
|
||||
paint_p99_us: u64,
|
||||
encode_p50_us: u64,
|
||||
encode_p95_us: u64,
|
||||
encode_p99_us: u64,
|
||||
write_p50_us: u64,
|
||||
write_p95_us: u64,
|
||||
write_p99_us: u64,
|
||||
total_p50_us: u64,
|
||||
total_p95_us: u64,
|
||||
total_p99_us: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LiveFrameReport {
|
||||
pub(super) loaded_url: Option<String>,
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) state: String,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
#[serde(default = "default_device_pixel_ratio")]
|
||||
pub(super) device_pixel_ratio: f32,
|
||||
#[serde(default)]
|
||||
pub(super) css_viewport_width: u32,
|
||||
#[serde(default)]
|
||||
pub(super) css_viewport_height: u32,
|
||||
pub(super) rgba_byte_count: usize,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) non_white_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) content_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) sample_hash: u64,
|
||||
}
|
||||
|
||||
fn default_device_pixel_ratio() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Per-frame tag that tells the renderer which already-imported
|
||||
/// `MTLTexture` to sample. Emitted at `trace` instead of `info` because
|
||||
/// it fires every frame on the hardware path; the import event above
|
||||
/// is the rare `info` and this trace is the steady-state breadcrumb.
|
||||
pub(super) fn log_iosurface_current(surface_id: u64) {
|
||||
tracing::trace!(
|
||||
target: "ely::servo::iosurface",
|
||||
surface_id,
|
||||
"iosurface_current",
|
||||
);
|
||||
}
|
||||
|
||||
/// Emit one structured `tracing` event per IOSurface handover, on a
|
||||
/// dedicated target so `RUST_LOG=ely::servo::iosurface=info` lights up
|
||||
/// the cross-process surface pipeline without pulling in everything
|
||||
/// else. The renderer (T10.4) will turn the same handle into an
|
||||
/// imported Metal texture; today the event is the observable contract
|
||||
/// that T10.3 plumbing is alive.
|
||||
pub(super) fn log_iosurface_handle(handle: &LiveSurfaceHandle) {
|
||||
tracing::info!(
|
||||
target: "ely::servo::iosurface",
|
||||
mach_port_name = handle.mach_port_name,
|
||||
surface_id = handle.surface_id,
|
||||
width = handle.width,
|
||||
height = handle.height,
|
||||
"iosurface_handle",
|
||||
);
|
||||
}
|
||||
|
||||
/// Emit one structured `tracing` event per perf summary, on a
|
||||
/// dedicated target so `RUST_LOG=ely::servo::perf=info` flips the
|
||||
/// stream on without dragging the rest of the app along. Filtering
|
||||
/// happens upstream in the subscriber — this call is a single
|
||||
/// pointer + integer push.
|
||||
pub(super) fn log_frame_perf(summary: &LiveFramePerfSummary) {
|
||||
tracing::info!(
|
||||
target: "ely::servo::perf",
|
||||
window = summary.window,
|
||||
context = %summary.context,
|
||||
paint_p50_us = summary.paint_p50_us,
|
||||
paint_p95_us = summary.paint_p95_us,
|
||||
paint_p99_us = summary.paint_p99_us,
|
||||
encode_p50_us = summary.encode_p50_us,
|
||||
encode_p95_us = summary.encode_p95_us,
|
||||
encode_p99_us = summary.encode_p99_us,
|
||||
write_p50_us = summary.write_p50_us,
|
||||
write_p95_us = summary.write_p95_us,
|
||||
write_p99_us = summary.write_p99_us,
|
||||
total_p50_us = summary.total_p50_us,
|
||||
total_p95_us = summary.total_p95_us,
|
||||
total_p99_us = summary.total_p99_us,
|
||||
"frame_perf",
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn close_request_serializes_to_wire() -> Result<(), serde_json::Error> {
|
||||
let value =
|
||||
serde_json::to_value(LiveRequest::Close { tab_id: "tab-live-close".to_string() })?;
|
||||
|
||||
assert_eq!(value, json!({"type": "close", "tab_id": "tab-live-close"}));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
use std::{
|
||||
env, io,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
const SIDECAR_PATH_ENV: &str = "ELY_SERVO_SIDECAR";
|
||||
const RENDERING_CONTEXT_ENV: &str = "ELY_SERVO_RENDERING_CONTEXT";
|
||||
const SOFTWARE_SIDECAR_FEATURES: &str = "servo-engine";
|
||||
const HARDWARE_SIDECAR_FEATURES: &str = "servo-engine,hardware-render";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) enum SidecarCommandTarget {
|
||||
Binary(PathBuf),
|
||||
Cargo { manifest_path: PathBuf },
|
||||
}
|
||||
|
||||
impl SidecarCommandTarget {
|
||||
pub(super) fn command(&self) -> Command {
|
||||
match self {
|
||||
Self::Binary(path) => Command::new(path),
|
||||
Self::Cargo { manifest_path } => {
|
||||
let mut command = Command::new("cargo");
|
||||
command
|
||||
.arg("run")
|
||||
.arg("--quiet")
|
||||
.arg("--manifest-path")
|
||||
.arg(manifest_path)
|
||||
.arg("-p")
|
||||
.arg("ely_servo_host")
|
||||
.arg("--features")
|
||||
.arg(sidecar_features_from_env())
|
||||
.arg("--bin")
|
||||
.arg("ely_servo_sidecar")
|
||||
.arg("--");
|
||||
command
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn missing_binary_path(&self) -> Option<&Path> {
|
||||
match self {
|
||||
Self::Binary(path) if !path.is_file() => Some(path.as_path()),
|
||||
Self::Binary(_) | Self::Cargo { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum SidecarRenderingContext {
|
||||
Software,
|
||||
Hardware,
|
||||
}
|
||||
|
||||
impl SidecarRenderingContext {
|
||||
pub(super) fn cli_arg(self) -> &'static str {
|
||||
match self {
|
||||
Self::Software => "software",
|
||||
Self::Hardware => "hardware",
|
||||
}
|
||||
}
|
||||
|
||||
fn sidecar_features(self) -> &'static str {
|
||||
match self {
|
||||
Self::Software => SOFTWARE_SIDECAR_FEATURES,
|
||||
Self::Hardware => HARDWARE_SIDECAR_FEATURES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum SidecarCommandError {
|
||||
#[error("current executable path is unavailable: {0}")]
|
||||
CurrentExecutable(#[source] io::Error),
|
||||
|
||||
#[error("current executable directory is unavailable for {path}")]
|
||||
CurrentExecutableDirectoryUnavailable { path: PathBuf },
|
||||
}
|
||||
|
||||
pub(super) fn default_sidecar_command() -> Result<SidecarCommandTarget, SidecarCommandError> {
|
||||
if let Some(path) = env::var_os(SIDECAR_PATH_ENV) {
|
||||
return Ok(SidecarCommandTarget::Binary(PathBuf::from(path)));
|
||||
}
|
||||
|
||||
let current_exe = env::current_exe().map_err(SidecarCommandError::CurrentExecutable)?;
|
||||
let exe_dir = current_exe.parent().ok_or_else(|| {
|
||||
SidecarCommandError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
|
||||
})?;
|
||||
let adjacent_sidecar = exe_dir.join(sidecar_binary_name());
|
||||
if adjacent_sidecar.is_file() && is_macos_app_bundle_exe_dir(exe_dir) {
|
||||
return Ok(SidecarCommandTarget::Binary(adjacent_sidecar));
|
||||
}
|
||||
let workspace_manifest = workspace_manifest_path();
|
||||
let workspace_target_sidecar =
|
||||
workspace_manifest.as_ref().and_then(|path| workspace_target_sidecar_path(path));
|
||||
let adjacent_is_workspace_target =
|
||||
workspace_target_sidecar.as_ref().is_some_and(|path| path == &adjacent_sidecar);
|
||||
let workspace_target_sidecar_exists =
|
||||
workspace_target_sidecar.as_ref().is_some_and(|path| path.is_file());
|
||||
let prefer_cargo_hardware_sidecar = rendering_context_from_env()
|
||||
== SidecarRenderingContext::Hardware
|
||||
&& (adjacent_is_workspace_target || workspace_target_sidecar_exists)
|
||||
&& workspace_manifest.as_ref().is_some_and(|path| path.is_file());
|
||||
if adjacent_sidecar.is_file() && !prefer_cargo_hardware_sidecar {
|
||||
return Ok(SidecarCommandTarget::Binary(adjacent_sidecar));
|
||||
}
|
||||
|
||||
if let Some(manifest_path) = workspace_manifest {
|
||||
if let Some(target_sidecar) = workspace_target_sidecar
|
||||
&& target_sidecar.is_file()
|
||||
&& !prefer_cargo_hardware_sidecar
|
||||
{
|
||||
return Ok(SidecarCommandTarget::Binary(target_sidecar));
|
||||
}
|
||||
if manifest_path.is_file() {
|
||||
return Ok(SidecarCommandTarget::Cargo { manifest_path });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SidecarCommandTarget::Binary(adjacent_sidecar))
|
||||
}
|
||||
|
||||
fn workspace_manifest_path() -> Option<PathBuf> {
|
||||
option_env!("ELY_WORKSPACE_MANIFEST").map(PathBuf::from)
|
||||
}
|
||||
|
||||
pub(super) fn rendering_context_from_env() -> SidecarRenderingContext {
|
||||
let raw = env::var(RENDERING_CONTEXT_ENV).ok();
|
||||
rendering_context_selection(raw.as_deref())
|
||||
}
|
||||
|
||||
fn sidecar_features_from_env() -> &'static str {
|
||||
rendering_context_from_env().sidecar_features()
|
||||
}
|
||||
|
||||
fn rendering_context_selection(raw: Option<&str>) -> SidecarRenderingContext {
|
||||
match raw.map(str::to_lowercase).as_deref() {
|
||||
Some("software") => SidecarRenderingContext::Software,
|
||||
Some("hardware") => SidecarRenderingContext::Hardware,
|
||||
_ => default_rendering_context(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_rendering_context() -> SidecarRenderingContext {
|
||||
if cfg!(target_os = "macos") {
|
||||
SidecarRenderingContext::Hardware
|
||||
} else {
|
||||
SidecarRenderingContext::Software
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_target_sidecar_path(manifest_path: &Path) -> Option<PathBuf> {
|
||||
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
|
||||
Some(manifest_path.parent()?.join("target").join(profile).join(sidecar_binary_name()))
|
||||
}
|
||||
|
||||
fn sidecar_binary_name() -> String {
|
||||
format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX)
|
||||
}
|
||||
|
||||
fn is_macos_app_bundle_exe_dir(path: &Path) -> bool {
|
||||
path.file_name().is_some_and(|name| name == "MacOS")
|
||||
&& path
|
||||
.parent()
|
||||
.is_some_and(|contents| contents.file_name().is_some_and(|name| name == "Contents"))
|
||||
&& path
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.is_some_and(|bundle| bundle.extension().is_some_and(|extension| extension == "app"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
HARDWARE_SIDECAR_FEATURES, SOFTWARE_SIDECAR_FEATURES, SidecarRenderingContext,
|
||||
is_macos_app_bundle_exe_dir, rendering_context_selection,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn hardware_rendering_context_enables_hardware_sidecar_feature() {
|
||||
let context = rendering_context_selection(Some("hardware"));
|
||||
assert_eq!(context, SidecarRenderingContext::Hardware);
|
||||
assert_eq!(context.sidecar_features(), HARDWARE_SIDECAR_FEATURES);
|
||||
assert_eq!(
|
||||
rendering_context_selection(Some("HARDWARE")),
|
||||
SidecarRenderingContext::Hardware
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_rendering_context_uses_software_sidecar_feature() {
|
||||
let context = rendering_context_selection(Some("software"));
|
||||
assert_eq!(context, SidecarRenderingContext::Software);
|
||||
assert_eq!(context.sidecar_features(), SOFTWARE_SIDECAR_FEATURES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_macos_app_bundle_executable_directory() {
|
||||
assert!(is_macos_app_bundle_exe_dir(std::path::Path::new(
|
||||
"/tmp/ELY Browser.app/Contents/MacOS"
|
||||
)));
|
||||
assert!(!is_macos_app_bundle_exe_dir(std::path::Path::new("/tmp/target/debug")));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn defaults_to_hardware_rendering_context_on_macos() {
|
||||
assert_eq!(rendering_context_selection(None), SidecarRenderingContext::Hardware);
|
||||
assert_eq!(rendering_context_selection(Some("garbage")), SidecarRenderingContext::Hardware);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn defaults_to_software_rendering_context_off_macos() {
|
||||
assert_eq!(rendering_context_selection(None), SidecarRenderingContext::Software);
|
||||
assert_eq!(rendering_context_selection(Some("garbage")), SidecarRenderingContext::Software);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
//! GPUI test harness for the input pipeline.
|
||||
//!
|
||||
//! Twelve sidecar-side commits and one shell-side commit had all claimed to
|
||||
//! Twelve renderer-side commits and one shell-side commit had all claimed to
|
||||
//! fix "click does nothing" while the user kept reporting the same symptom.
|
||||
//! The roundtable consensus: every store-layer test passed GREEN, every
|
||||
//! sidecar integration test passed GREEN, but nothing in the repo exercised
|
||||
//! renderer integration test passed GREEN, but nothing in the repo exercised
|
||||
//! the real GPUI event tree (`render_input_overlay` + window-level mouse
|
||||
//! handlers + sidebar capture interactions). This module is that missing
|
||||
//! holdout set.
|
||||
@@ -984,8 +984,7 @@ async fn baseline_overlay_div_receives_simulated_click(cx: &mut TestAppContext)
|
||||
/// **byte-identical RGBA payloads must produce the same underlying
|
||||
/// `Arc<RenderImage>`**. Today they do not — every `from_live_frame`
|
||||
/// blindly reallocates. The fix path is either dedup the upload
|
||||
/// against the last bytes or switch to `OffscreenRenderingContext` +
|
||||
/// IOSurface so the GPU texture is the source of truth.
|
||||
/// against the last bytes or switch to direct platform-surface presentation.
|
||||
///
|
||||
/// Regression guard: with the single-slot `LAST_FRAME_IMAGE` cache in
|
||||
/// `web_surface_frame.rs`, two `ServoLiveFrame` inputs carrying
|
||||
@@ -1028,7 +1027,7 @@ fn identical_live_frames_share_render_image_arc() -> Result<(), String> {
|
||||
distinct Arc<RenderImage> instances (first={:p}, second={:p}). \
|
||||
WebSurfaceFrame::from_parts must dedup the upload against the \
|
||||
previous frame's bytes, or the rendering pipeline must switch \
|
||||
to a GPU-side source of truth (IOSurface) so per-frame host \
|
||||
to direct platform-surface presentation so per-frame host \
|
||||
allocations stop entirely.",
|
||||
Arc::as_ptr(first_image),
|
||||
Arc::as_ptr(second_image),
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_domain::{BrowserTab, TabId};
|
||||
use gpui::{Bounds, Pixels, Point};
|
||||
use gpui::{Bounds, NativeSurfaceHandle, Pixels, Point};
|
||||
|
||||
use crate::services::ProfileDataMode;
|
||||
|
||||
@@ -56,8 +56,22 @@ impl WebSurfaceStore {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let ensure_key =
|
||||
WebSurfaceEnsureKey::new(requested_url.clone(), size, tab.zoom_percent(), permissions);
|
||||
let native_surface =
|
||||
self.surfaces.get(tab.id()).and_then(|surface| surface.native_surface.clone());
|
||||
#[cfg(not(test))]
|
||||
let Some(native_surface) = native_surface else {
|
||||
return false;
|
||||
};
|
||||
let ensure_key = WebSurfaceEnsureKey::new(
|
||||
requested_url.clone(),
|
||||
size,
|
||||
#[cfg(test)]
|
||||
native_surface.as_ref(),
|
||||
#[cfg(not(test))]
|
||||
Some(&native_surface),
|
||||
tab.zoom_percent(),
|
||||
permissions,
|
||||
);
|
||||
if self.surfaces.get(tab.id()).is_some_and(|surface| !surface.should_ensure(&ensure_key)) {
|
||||
return false;
|
||||
}
|
||||
@@ -65,7 +79,29 @@ impl WebSurfaceStore {
|
||||
let previous_frame =
|
||||
self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent());
|
||||
|
||||
match self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input) {
|
||||
#[cfg(test)]
|
||||
let ensure_result = match native_surface {
|
||||
Some(native_surface) => self.runtime.ensure_tab_with_native_surface(
|
||||
tab,
|
||||
size,
|
||||
native_surface,
|
||||
profile_data_mode,
|
||||
permissions,
|
||||
input,
|
||||
),
|
||||
None => self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input),
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
let ensure_result = self.runtime.ensure_tab_with_native_surface(
|
||||
tab,
|
||||
size,
|
||||
native_surface,
|
||||
profile_data_mode,
|
||||
permissions,
|
||||
input,
|
||||
);
|
||||
|
||||
match ensure_result {
|
||||
Ok(result) => {
|
||||
self.surface_mut(tab.id()).mark_ensured(ensure_key);
|
||||
if result.started_loading {
|
||||
@@ -241,6 +277,24 @@ impl WebSurfaceStore {
|
||||
WebSurfaceInputOutcome::Applied
|
||||
}
|
||||
|
||||
pub(super) fn record_native_surface(
|
||||
&mut self,
|
||||
tab_id: &TabId,
|
||||
native_surface: NativeSurfaceHandle,
|
||||
) -> WebSurfaceInputOutcome {
|
||||
let surface = self.surface_mut(tab_id);
|
||||
if surface
|
||||
.native_surface
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.identity() == native_surface.identity())
|
||||
{
|
||||
return WebSurfaceInputOutcome::NoChange;
|
||||
}
|
||||
surface.native_surface = Some(native_surface);
|
||||
surface.last_ensure_key = None;
|
||||
WebSurfaceInputOutcome::Applied
|
||||
}
|
||||
|
||||
pub(super) fn record_hover_point(
|
||||
&mut self,
|
||||
tab_id: &TabId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use ely_browser_core::{BrowserCore, BrowserSnapshot};
|
||||
use ely_domain::{BrowserTab, ProfileKind, TabId, UrlText};
|
||||
use gpui::{AnyElement, Bounds, Context, Pixels, Point};
|
||||
use gpui::{AnyElement, Bounds, Context, NativeSurfaceHandle, Pixels, Point};
|
||||
|
||||
use crate::services::ProfileDataMode;
|
||||
|
||||
@@ -101,6 +101,25 @@ impl ElyShell {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_external_web_surface(
|
||||
&mut self,
|
||||
tab_id: TabId,
|
||||
bounds: Bounds<Pixels>,
|
||||
scale_factor: f32,
|
||||
native_surface: NativeSurfaceHandle,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let viewport_changed =
|
||||
self.web_surfaces.record_viewport_size(&tab_id, bounds, scale_factor)
|
||||
== WebSurfaceInputOutcome::Applied;
|
||||
let surface_changed = self.web_surfaces.record_native_surface(&tab_id, native_surface)
|
||||
== WebSurfaceInputOutcome::Applied;
|
||||
|
||||
if viewport_changed || surface_changed {
|
||||
self.flush_external_web_surface_tick(cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn scroll_external_web_viewport(
|
||||
&mut self,
|
||||
tab_id: TabId,
|
||||
|
||||
@@ -3,10 +3,6 @@ use std::hash::Hasher;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ahash::AHasher;
|
||||
#[cfg(target_os = "macos")]
|
||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
||||
use core_video::{pixel_buffer::kCVPixelBufferLock_ReadOnly, r#return::kCVReturnSuccess};
|
||||
use gpui::RenderImage;
|
||||
use image::{ImageBuffer, Rgba};
|
||||
use thiserror::Error;
|
||||
@@ -47,11 +43,7 @@ pub(super) struct WebSurfaceFrame {
|
||||
content_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: u64,
|
||||
/// Software-path image built from RGBA bytes.
|
||||
pub(super) image: Option<Arc<RenderImage>>,
|
||||
/// Hardware-path IOSurface imported from the sidecar.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) pixel_buffer: Option<CVPixelBuffer>,
|
||||
}
|
||||
|
||||
impl WebSurfaceFrame {
|
||||
@@ -61,8 +53,6 @@ impl WebSurfaceFrame {
|
||||
zoom_percent: u16,
|
||||
frame: ServoLiveFrame,
|
||||
) -> Result<Self, WebSurfaceError> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let pixel_buffer = frame.pixel_buffer().cloned();
|
||||
Self::from_parts(WebSurfaceFrameParts {
|
||||
requested_url,
|
||||
loaded_url: frame.loaded_url().map(str::to_string),
|
||||
@@ -84,45 +74,24 @@ impl WebSurfaceFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: frame.sample_hash(),
|
||||
rgba_bytes: frame.into_rgba_bytes(),
|
||||
#[cfg(target_os = "macos")]
|
||||
pixel_buffer,
|
||||
})
|
||||
}
|
||||
|
||||
fn from_parts(parts: WebSurfaceFrameParts) -> Result<Self, WebSurfaceError> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let has_pixel_buffer = parts.pixel_buffer.is_some();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let has_pixel_buffer = false;
|
||||
|
||||
if parts.rgba_bytes.is_empty() && !has_pixel_buffer {
|
||||
return Err(WebSurfaceError::MissingRenderablePayload);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(pixel_buffer) = parts.pixel_buffer.as_ref() {
|
||||
validate_hardware_pixel_buffer(pixel_buffer, parts.width, parts.height)?;
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
let pixel_sample = pixel_sample_for_parts(&parts)?;
|
||||
|
||||
let image = if parts.rgba_bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Servo's `read_pixels(gl::RGBA, gl::UNSIGNED_BYTE)` writes
|
||||
// R-G-B-A in memory order. GPUI's `RenderImage` is documented
|
||||
// as "in BGRA format" and uploads via
|
||||
// `MTLPixelFormat::BGRA8Unorm`, which reads B-G-R-A. Hand the bytes across
|
||||
// unchanged and the Metal sampler treats R as B (and vice
|
||||
// versa) — every coloured pixel renders with R and B swapped.
|
||||
// Swap once here so the rest of the pipeline (dedup hash,
|
||||
// image buffer, GPU upload) all operate on the same BGRA
|
||||
// representation.
|
||||
let mut bytes = parts.rgba_bytes;
|
||||
swap_red_blue_in_place(&mut bytes);
|
||||
let bytes_hash = rgba_hash(&bytes);
|
||||
Some(resolve_render_image(parts.width, parts.height, bytes, bytes_hash)?)
|
||||
let image = match parts.rgba_bytes {
|
||||
Some(mut bytes) => {
|
||||
if bytes.is_empty() {
|
||||
return Err(WebSurfaceError::MissingRenderablePayload);
|
||||
}
|
||||
// Servo's RGBA8 readback is converted once for GPUI's BGRA upload path.
|
||||
swap_red_blue_in_place(&mut bytes);
|
||||
let bytes_hash = rgba_hash(&bytes);
|
||||
Some(resolve_render_image(parts.width, parts.height, bytes, bytes_hash)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
@@ -146,8 +115,6 @@ impl WebSurfaceFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: pixel_sample.sample_hash,
|
||||
image,
|
||||
#[cfg(target_os = "macos")]
|
||||
pixel_buffer: parts.pixel_buffer,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -214,15 +181,13 @@ impl WebSurfaceFrame {
|
||||
}
|
||||
|
||||
pub(super) fn has_same_software_render_as(&self, other: &Self) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.pixel_buffer.is_some() || other.pixel_buffer.is_some() {
|
||||
return false;
|
||||
}
|
||||
let (Some(image), Some(other_image)) = (self.image.as_ref(), other.image.as_ref()) else {
|
||||
return false;
|
||||
let image_matches = match (self.image.as_ref(), other.image.as_ref()) {
|
||||
(Some(image), Some(other_image)) => Arc::ptr_eq(image, other_image),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
Arc::ptr_eq(image, other_image)
|
||||
image_matches
|
||||
&& self.requested_url == other.requested_url
|
||||
&& self.loaded_url == other.loaded_url
|
||||
&& self.title == other.title
|
||||
@@ -239,7 +204,6 @@ impl WebSurfaceFrame {
|
||||
}
|
||||
|
||||
pub(super) fn has_visible_content_for_initial_display(&self) -> Result<bool, WebSurfaceError> {
|
||||
// Sidecar readback suppresses blank initial frames before publication.
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
{
|
||||
Ok(self.non_white_pixel_count > 0 && self.content_pixel_count > 0)
|
||||
@@ -267,14 +231,7 @@ impl WebSurfaceFrame {
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn has_hardware_surface(&self) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.pixel_buffer.is_some()
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,67 +255,15 @@ struct WebSurfaceFrameParts {
|
||||
content_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: u64,
|
||||
rgba_bytes: Vec<u8>,
|
||||
#[cfg(target_os = "macos")]
|
||||
pixel_buffer: Option<CVPixelBuffer>,
|
||||
rgba_bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(super) enum WebSurfaceError {
|
||||
#[error("invalid servo frame buffer for {width}x{height}")]
|
||||
InvalidFrameBuffer { width: u32, height: u32 },
|
||||
#[error("servo live frame did not include a software image or hardware IOSurface")]
|
||||
#[error("servo live frame did not include renderable pixels")]
|
||||
MissingRenderablePayload,
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error(
|
||||
"servo hardware surface size {actual_width}x{actual_height} did not match frame report {expected_width}x{expected_height}"
|
||||
)]
|
||||
HardwareSurfaceSizeMismatch {
|
||||
expected_width: u32,
|
||||
expected_height: u32,
|
||||
actual_width: usize,
|
||||
actual_height: usize,
|
||||
},
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("servo hardware surface pixel format 0x{actual:x} is unsupported; expected 32BGRA")]
|
||||
UnsupportedHardwareSurfaceFormat { actual: u32 },
|
||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
||||
#[error("servo hardware surface lock failed with status {status}")]
|
||||
HardwareSurfaceLockFailed { status: i32 },
|
||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
||||
#[error("servo hardware surface unlock failed with status {status}")]
|
||||
HardwareSurfaceUnlockFailed { status: i32 },
|
||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
||||
#[error("servo hardware surface base address is unavailable")]
|
||||
HardwareSurfaceBaseAddressUnavailable,
|
||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
||||
#[error("servo hardware surface row stride {bytes_per_row} is too small for width {width}")]
|
||||
HardwareSurfaceRowStrideTooSmall { width: usize, bytes_per_row: usize },
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn validate_hardware_pixel_buffer(
|
||||
pixel_buffer: &CVPixelBuffer,
|
||||
expected_width: u32,
|
||||
expected_height: u32,
|
||||
) -> Result<(), WebSurfaceError> {
|
||||
let actual_width = pixel_buffer.get_width();
|
||||
let actual_height = pixel_buffer.get_height();
|
||||
if actual_width != expected_width as usize || actual_height != expected_height as usize {
|
||||
return Err(WebSurfaceError::HardwareSurfaceSizeMismatch {
|
||||
expected_width,
|
||||
expected_height,
|
||||
actual_width,
|
||||
actual_height,
|
||||
});
|
||||
}
|
||||
|
||||
let actual_format = pixel_buffer.get_pixel_format();
|
||||
if actual_format != kCVPixelFormatType_32BGRA {
|
||||
return Err(WebSurfaceError::UnsupportedHardwareSurfaceFormat { actual: actual_format });
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Swap byte 0 and byte 2 of every 4-byte pixel, converting Servo's
|
||||
@@ -387,11 +292,6 @@ struct WebSurfacePixelSample {
|
||||
fn pixel_sample_for_parts(
|
||||
parts: &WebSurfaceFrameParts,
|
||||
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(pixel_buffer) = parts.pixel_buffer.as_ref() {
|
||||
return sample_hardware_pixel_buffer(pixel_buffer);
|
||||
}
|
||||
|
||||
Ok(WebSurfacePixelSample {
|
||||
non_white_pixel_count: parts.non_white_pixel_count,
|
||||
content_pixel_count: parts.content_pixel_count,
|
||||
@@ -399,82 +299,6 @@ fn pixel_sample_for_parts(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
||||
fn sample_hardware_pixel_buffer(
|
||||
pixel_buffer: &CVPixelBuffer,
|
||||
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
|
||||
let lock_status = pixel_buffer.lock_base_address(kCVPixelBufferLock_ReadOnly);
|
||||
if lock_status != kCVReturnSuccess {
|
||||
return Err(WebSurfaceError::HardwareSurfaceLockFailed { status: lock_status });
|
||||
}
|
||||
|
||||
let sample = sample_locked_hardware_pixel_buffer(pixel_buffer);
|
||||
let unlock_status = pixel_buffer.unlock_base_address(kCVPixelBufferLock_ReadOnly);
|
||||
if unlock_status != kCVReturnSuccess {
|
||||
return Err(WebSurfaceError::HardwareSurfaceUnlockFailed { status: unlock_status });
|
||||
}
|
||||
|
||||
sample
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
||||
fn sample_locked_hardware_pixel_buffer(
|
||||
pixel_buffer: &CVPixelBuffer,
|
||||
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
|
||||
let width = pixel_buffer.get_width();
|
||||
let height = pixel_buffer.get_height();
|
||||
let bytes_per_row = pixel_buffer.get_bytes_per_row();
|
||||
let row_width = width.saturating_mul(4);
|
||||
if bytes_per_row < row_width {
|
||||
return Err(WebSurfaceError::HardwareSurfaceRowStrideTooSmall { width, bytes_per_row });
|
||||
}
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
let base_address = unsafe { pixel_buffer.get_base_address() };
|
||||
if base_address.is_null() {
|
||||
return Err(WebSurfaceError::HardwareSurfaceBaseAddressUnavailable);
|
||||
}
|
||||
|
||||
let byte_len = bytes_per_row.saturating_mul(height);
|
||||
#[expect(unsafe_code)]
|
||||
let bytes = unsafe { std::slice::from_raw_parts(base_address.cast::<u8>(), byte_len) };
|
||||
Ok(sample_bgra_rows(bytes, width, height, bytes_per_row))
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
||||
fn sample_bgra_rows(
|
||||
bytes: &[u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
bytes_per_row: usize,
|
||||
) -> WebSurfacePixelSample {
|
||||
let mut non_white_pixel_count = 0;
|
||||
let mut content_pixel_count = 0;
|
||||
let mut sample_hash = 0xcbf29ce484222325_u64;
|
||||
|
||||
for y in 0..height {
|
||||
let row_start = y * bytes_per_row;
|
||||
let row = &bytes[row_start..row_start + width * 4];
|
||||
for (x, pixel) in row.chunks_exact(4).enumerate() {
|
||||
let [blue, green, red, alpha] = [pixel[0], pixel[1], pixel[2], pixel[3]];
|
||||
if alpha > 0 && (red < 245 || green < 245 || blue < 245) {
|
||||
non_white_pixel_count += 1;
|
||||
}
|
||||
if alpha > 0 && (red < 220 || green < 220 || blue < 220) {
|
||||
content_pixel_count += 1;
|
||||
}
|
||||
if (y * width + x).is_multiple_of(97) {
|
||||
for byte in [red, green, blue, alpha] {
|
||||
sample_hash ^= u64::from(byte);
|
||||
sample_hash = sample_hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WebSurfacePixelSample { non_white_pixel_count, content_pixel_count, sample_hash }
|
||||
}
|
||||
|
||||
fn resolve_render_image(
|
||||
width: u32,
|
||||
height: u32,
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::{
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, TabId};
|
||||
use gpui::NativeSurfaceHandle;
|
||||
|
||||
use crate::services::{
|
||||
ProfileDataMode,
|
||||
@@ -23,7 +24,9 @@ use super::{
|
||||
};
|
||||
|
||||
pub(super) struct WebSurfaceRuntime {
|
||||
workers: BTreeMap<WebSurfaceRuntimeScope, ScopedWorker>,
|
||||
worker: Option<ScopedWorker>,
|
||||
direct_client: Option<ScopedDirectClient>,
|
||||
pending_direct_responses: Vec<WorkerResponse>,
|
||||
sessions: BTreeMap<TabId, WebSurfaceSession>,
|
||||
client_factory: LiveRuntimeClientFactory,
|
||||
}
|
||||
@@ -31,7 +34,9 @@ pub(super) struct WebSurfaceRuntime {
|
||||
impl WebSurfaceRuntime {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
workers: BTreeMap::new(),
|
||||
worker: None,
|
||||
direct_client: None,
|
||||
pending_direct_responses: Vec::new(),
|
||||
sessions: BTreeMap::new(),
|
||||
client_factory: new_servo_live_client,
|
||||
}
|
||||
@@ -39,9 +44,16 @@ impl WebSurfaceRuntime {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self {
|
||||
Self { workers: BTreeMap::new(), sessions: BTreeMap::new(), client_factory }
|
||||
Self {
|
||||
worker: None,
|
||||
direct_client: None,
|
||||
pending_direct_responses: Vec::new(),
|
||||
sessions: BTreeMap::new(),
|
||||
client_factory,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn ensure_tab(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
@@ -49,11 +61,48 @@ impl WebSurfaceRuntime {
|
||||
profile_data_mode: ProfileDataMode,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
input: WebSurfacePendingInput,
|
||||
) -> Result<WebSurfaceEnsureResult, String> {
|
||||
self.ensure_tab_inner(tab, size, None, profile_data_mode, permissions, input)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_tab_with_native_surface(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
size: WebSurfaceSize,
|
||||
native_surface: NativeSurfaceHandle,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
input: WebSurfacePendingInput,
|
||||
) -> Result<WebSurfaceEnsureResult, String> {
|
||||
self.ensure_tab_inner(
|
||||
tab,
|
||||
size,
|
||||
Some(native_surface),
|
||||
profile_data_mode,
|
||||
permissions,
|
||||
input,
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_tab_inner(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
size: WebSurfaceSize,
|
||||
native_surface: Option<NativeSurfaceHandle>,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
input: WebSurfacePendingInput,
|
||||
) -> Result<WebSurfaceEnsureResult, String> {
|
||||
let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode);
|
||||
self.ensure_worker(scope.clone())?;
|
||||
let use_direct_client = native_surface.is_some();
|
||||
if use_direct_client {
|
||||
self.ensure_direct_client(scope.clone())?;
|
||||
} else {
|
||||
self.ensure_worker(scope.clone())?;
|
||||
}
|
||||
|
||||
let requested_url = tab.url().as_str().to_string();
|
||||
let tab_id_string = tab.id().as_str().to_string();
|
||||
let zoom_percent = tab.zoom_percent();
|
||||
let enqueued_at = input.enqueued_at;
|
||||
let input_kind = pending_input_kind(&input);
|
||||
@@ -88,6 +137,7 @@ impl WebSurfaceRuntime {
|
||||
height: size.height,
|
||||
page_zoom_percent: zoom_percent,
|
||||
device_pixel_ratio: size.device_pixel_ratio_f32(),
|
||||
native_surface,
|
||||
scroll_delta_x,
|
||||
scroll_delta_y,
|
||||
scroll_point_x,
|
||||
@@ -100,10 +150,15 @@ impl WebSurfaceRuntime {
|
||||
site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(),
|
||||
};
|
||||
|
||||
let Some(scoped) = self.workers.get(&scope) else {
|
||||
return Err("Servo worker was created but is no longer registered".to_string());
|
||||
};
|
||||
scoped.worker.submit_ensure(request);
|
||||
if use_direct_client {
|
||||
let response = self.ensure_direct(request, tab_id_string.clone())?;
|
||||
self.pending_direct_responses.extend(response);
|
||||
} else {
|
||||
let Some(scoped) = self.worker.as_ref() else {
|
||||
return Err("Servo worker was created but is no longer registered".to_string());
|
||||
};
|
||||
scoped.worker.submit_ensure(request);
|
||||
}
|
||||
if let Some(session) = self.sessions.get_mut(tab.id()) {
|
||||
session.cadence.note_poll_submitted(submitted_at);
|
||||
}
|
||||
@@ -114,68 +169,19 @@ impl WebSurfaceRuntime {
|
||||
|
||||
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> {
|
||||
let mut frames = Vec::new();
|
||||
let mut dead_scopes = Vec::new();
|
||||
let scopes: Vec<WebSurfaceRuntimeScope> = self.workers.keys().cloned().collect();
|
||||
let now = Instant::now();
|
||||
for scope in scopes {
|
||||
let responses = self
|
||||
.workers
|
||||
.get(&scope)
|
||||
.map(|scoped| scoped.worker.drain_responses())
|
||||
.unwrap_or_default();
|
||||
for response in responses {
|
||||
match response {
|
||||
WorkerResponse::Frame { tab_id, frame } => {
|
||||
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
|
||||
continue;
|
||||
};
|
||||
let session = match self.sessions.get_mut(&tab_id_obj) {
|
||||
Some(session) => session,
|
||||
None => continue,
|
||||
};
|
||||
let requested_url = session.requested_url.clone();
|
||||
let scroll_offset = session.scroll_offset;
|
||||
let zoom_percent = session.zoom_percent;
|
||||
session.cadence.note_frame(frame.render_state(), now);
|
||||
match WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
scroll_offset,
|
||||
zoom_percent,
|
||||
frame,
|
||||
) {
|
||||
Ok(frame) => {
|
||||
let url_change = session.url_change_for(
|
||||
&tab_id_obj,
|
||||
requested_url.as_str(),
|
||||
&frame,
|
||||
);
|
||||
frames.push(WebSurfaceRuntimeFrame::Ready {
|
||||
tab_id: tab_id_obj,
|
||||
frame: Box::new(frame),
|
||||
url_change,
|
||||
});
|
||||
}
|
||||
Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed {
|
||||
tab_id: tab_id_obj,
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
WorkerResponse::Failed { tab_id, message } => {
|
||||
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
|
||||
continue;
|
||||
};
|
||||
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
|
||||
}
|
||||
WorkerResponse::SidecarExited => dead_scopes.push(scope.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
for scope in dead_scopes {
|
||||
self.workers.remove(&scope);
|
||||
let mut responses = std::mem::take(&mut self.pending_direct_responses);
|
||||
responses.extend(
|
||||
self.worker.as_ref().map(|scoped| scoped.worker.drain_responses()).unwrap_or_default(),
|
||||
);
|
||||
let runtime_unavailable = self.collect_responses(responses, now, &mut frames);
|
||||
if runtime_unavailable {
|
||||
self.remove_worker();
|
||||
self.remove_direct_client();
|
||||
}
|
||||
|
||||
let poll_now = Instant::now();
|
||||
let mut direct_polls = Vec::new();
|
||||
for tab_id in visible_tab_ids {
|
||||
let Some(session) = self.sessions.get_mut(tab_id) else {
|
||||
continue;
|
||||
@@ -183,11 +189,22 @@ impl WebSurfaceRuntime {
|
||||
if !session.cadence.should_poll(poll_now) {
|
||||
continue;
|
||||
}
|
||||
let Some(scoped) = self.workers.get(&session.scope) else {
|
||||
continue;
|
||||
};
|
||||
let _ = scoped.worker.submit_poll(tab_id.as_str().to_string());
|
||||
session.cadence.note_poll_submitted(poll_now);
|
||||
if let Some(scoped) = self.worker.as_ref() {
|
||||
let _ = scoped.worker.submit_poll(tab_id.as_str().to_string());
|
||||
session.cadence.note_poll_submitted(poll_now);
|
||||
} else if self.direct_client.is_some() {
|
||||
direct_polls.push(tab_id.as_str().to_string());
|
||||
session.cadence.note_poll_submitted(poll_now);
|
||||
}
|
||||
}
|
||||
|
||||
if !direct_polls.is_empty() {
|
||||
let (responses, runtime_unavailable) = self.poll_direct(direct_polls);
|
||||
let unavailable_from_responses =
|
||||
self.collect_responses(responses, Instant::now(), &mut frames);
|
||||
if runtime_unavailable || unavailable_from_responses {
|
||||
self.remove_direct_client();
|
||||
}
|
||||
}
|
||||
|
||||
frames
|
||||
@@ -201,38 +218,156 @@ impl WebSurfaceRuntime {
|
||||
visible_tab_ids
|
||||
.iter()
|
||||
.filter_map(|tab_id| self.sessions.get(tab_id))
|
||||
.filter(|session| self.workers.contains_key(&session.scope))
|
||||
.filter(|_| self.worker.is_some() || self.direct_client.is_some())
|
||||
.map(|session| session.cadence.next_poll_delay(now))
|
||||
.min()
|
||||
}
|
||||
|
||||
pub(super) fn close_tab(&mut self, tab_id: &TabId) {
|
||||
let Some(session) = self.sessions.remove(tab_id) else {
|
||||
if self.sessions.remove(tab_id).is_none() {
|
||||
return;
|
||||
};
|
||||
if let Some(scoped) = self.workers.get(&session.scope) {
|
||||
}
|
||||
let direct_result = self
|
||||
.direct_client
|
||||
.as_mut()
|
||||
.map(|scoped| scoped.client.close(tab_id.as_str().to_string()));
|
||||
if direct_result.as_ref().is_some_and(|result| {
|
||||
result.as_ref().is_err_and(|error| error.is_runtime_unavailable())
|
||||
}) {
|
||||
self.remove_direct_client();
|
||||
}
|
||||
if let Some(scoped) = self.worker.as_ref() {
|
||||
scoped.worker.submit_close(tab_id.as_str().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
|
||||
if self.workers.contains_key(&scope) {
|
||||
if self.worker.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
|
||||
let client_factory = self.client_factory;
|
||||
let worker = LiveRuntimeWorker::new(move || client_factory(config_dir))?;
|
||||
self.worker = Some(ScopedWorker { worker, transient_profile_data_dir });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_direct_client(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
|
||||
if self.direct_client.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
|
||||
let client = (self.client_factory)(config_dir)?;
|
||||
let worker = LiveRuntimeWorker::new(client)?;
|
||||
self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_dir });
|
||||
self.direct_client = Some(ScopedDirectClient { client, transient_profile_data_dir });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_direct(
|
||||
&mut self,
|
||||
request: ServoLiveEnsureRequest,
|
||||
tab_id: String,
|
||||
) -> Result<Option<WorkerResponse>, String> {
|
||||
let Some(scoped) = self.direct_client.as_mut() else {
|
||||
return Err("Servo client was created but is no longer registered".to_string());
|
||||
};
|
||||
match scoped.client.ensure(request) {
|
||||
Ok(Some(frame)) => Ok(Some(WorkerResponse::Frame { tab_id, frame })),
|
||||
Ok(None) => Ok(None),
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
if error.is_runtime_unavailable() {
|
||||
self.remove_direct_client();
|
||||
}
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_direct(&mut self, tab_ids: Vec<String>) -> (Vec<WorkerResponse>, bool) {
|
||||
let Some(scoped) = self.direct_client.as_mut() else {
|
||||
return (Vec::new(), false);
|
||||
};
|
||||
let mut responses = Vec::new();
|
||||
let mut runtime_unavailable = false;
|
||||
for tab_id in tab_ids {
|
||||
match scoped.client.poll(tab_id.clone()) {
|
||||
Ok(Some(frame)) => responses.push(WorkerResponse::Frame { tab_id, frame }),
|
||||
Ok(None) => {}
|
||||
Err(error) if error.is_runtime_unavailable() => {
|
||||
runtime_unavailable = true;
|
||||
responses.push(WorkerResponse::RuntimeUnavailable);
|
||||
}
|
||||
Err(error) => {
|
||||
responses.push(WorkerResponse::Failed { tab_id, message: error.to_string() })
|
||||
}
|
||||
}
|
||||
}
|
||||
(responses, runtime_unavailable)
|
||||
}
|
||||
|
||||
fn collect_responses(
|
||||
&mut self,
|
||||
responses: Vec<WorkerResponse>,
|
||||
now: Instant,
|
||||
frames: &mut Vec<WebSurfaceRuntimeFrame>,
|
||||
) -> bool {
|
||||
let mut runtime_unavailable = false;
|
||||
for response in responses {
|
||||
match response {
|
||||
WorkerResponse::Frame { tab_id, frame } => {
|
||||
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
|
||||
continue;
|
||||
};
|
||||
let session = match self.sessions.get_mut(&tab_id_obj) {
|
||||
Some(session) => session,
|
||||
None => continue,
|
||||
};
|
||||
let requested_url = session.requested_url.clone();
|
||||
let scroll_offset = session.scroll_offset;
|
||||
let zoom_percent = session.zoom_percent;
|
||||
session.cadence.note_frame(frame.render_state(), now);
|
||||
match WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
scroll_offset,
|
||||
zoom_percent,
|
||||
frame,
|
||||
) {
|
||||
Ok(frame) => {
|
||||
let url_change =
|
||||
session.url_change_for(&tab_id_obj, requested_url.as_str(), &frame);
|
||||
frames.push(WebSurfaceRuntimeFrame::Ready {
|
||||
tab_id: tab_id_obj,
|
||||
frame: Box::new(frame),
|
||||
url_change,
|
||||
});
|
||||
}
|
||||
Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed {
|
||||
tab_id: tab_id_obj,
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
WorkerResponse::Failed { tab_id, message } => {
|
||||
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
|
||||
continue;
|
||||
};
|
||||
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
|
||||
}
|
||||
WorkerResponse::RuntimeUnavailable => {
|
||||
runtime_unavailable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime_unavailable
|
||||
}
|
||||
|
||||
fn lookup_session_tab_id(&self, tab_id: &str) -> Option<TabId> {
|
||||
self.sessions.keys().find(|key| key.as_str() == tab_id).cloned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn client_count_for_test(&self) -> usize {
|
||||
self.workers.len()
|
||||
usize::from(self.worker.is_some()) + usize::from(self.direct_client.is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -242,23 +377,34 @@ impl WebSurfaceRuntime {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn flush_for_test(&self) {
|
||||
for scoped in self.workers.values() {
|
||||
if let Some(scoped) = self.worker.as_ref() {
|
||||
scoped.worker.wait_until_idle();
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_worker(&mut self) {
|
||||
let Some(scoped) = self.worker.take() else {
|
||||
return;
|
||||
};
|
||||
if let Some(path) = scoped.transient_profile_data_dir {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_direct_client(&mut self) {
|
||||
let Some(scoped) = self.direct_client.take() else {
|
||||
return;
|
||||
};
|
||||
if let Some(path) = scoped.transient_profile_data_dir {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WebSurfaceRuntime {
|
||||
fn drop(&mut self) {
|
||||
let transient_profile_data_dirs = self
|
||||
.workers
|
||||
.values()
|
||||
.filter_map(|scoped| scoped.transient_profile_data_dir.clone())
|
||||
.collect::<Vec<_>>();
|
||||
self.workers.clear();
|
||||
for path in transient_profile_data_dirs {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
self.remove_worker();
|
||||
self.remove_direct_client();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +422,11 @@ struct ScopedWorker {
|
||||
transient_profile_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
struct ScopedDirectClient {
|
||||
client: Box<dyn LiveRuntimeClient>,
|
||||
transient_profile_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub(super) struct WebSurfaceRuntimeScope {
|
||||
profile_id: ProfileId,
|
||||
|
||||
@@ -29,7 +29,7 @@ static FAILING_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static REPEATED_FRAME_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
#[test]
|
||||
fn runtime_keeps_independent_clients_for_profile_scopes() -> Result<(), String> {
|
||||
fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory);
|
||||
let first_profile = ProfileId::new();
|
||||
let second_profile = ProfileId::new();
|
||||
@@ -60,7 +60,7 @@ fn runtime_keeps_independent_clients_for_profile_scopes() -> Result<(), String>
|
||||
|
||||
runtime.flush_for_test();
|
||||
|
||||
assert_eq!(runtime.client_count_for_test(), 2);
|
||||
assert_eq!(runtime.client_count_for_test(), 1);
|
||||
assert_eq!(
|
||||
runtime.session_scope_for_test(first_tab.id()),
|
||||
Some(&WebSurfaceRuntimeScope::new(first_profile, ProfileDataMode::Transient)),
|
||||
@@ -183,7 +183,7 @@ fn identical_ready_software_frame_keeps_tick_unchanged() -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_exit_removes_dead_runtime_client() -> Result<(), String> {
|
||||
fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> {
|
||||
RECOVERY_FACTORY_COUNT.store(0, Ordering::SeqCst);
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(recovery_client_factory);
|
||||
let profile = ProfileId::new();
|
||||
@@ -281,7 +281,7 @@ fn session_scope_change_resets_tab_state() {
|
||||
|
||||
struct FakeLiveRuntimeClient;
|
||||
struct IdleSkipLiveRuntimeClient;
|
||||
struct SidecarExitLiveRuntimeClient;
|
||||
struct RuntimeUnavailableLiveRuntimeClient;
|
||||
struct FailingLiveRuntimeClient;
|
||||
struct RepeatedFrameLiveRuntimeClient;
|
||||
|
||||
@@ -322,20 +322,20 @@ impl LiveRuntimeClient for IdleSkipLiveRuntimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveRuntimeClient for SidecarExitLiveRuntimeClient {
|
||||
impl LiveRuntimeClient for RuntimeUnavailableLiveRuntimeClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Err(LiveRuntimeClientError::SidecarExited)
|
||||
Err(LiveRuntimeClientError::RuntimeUnavailable)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Err(LiveRuntimeClientError::SidecarExited)
|
||||
Err(LiveRuntimeClientError::RuntimeUnavailable)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Err(LiveRuntimeClientError::SidecarExited)
|
||||
Err(LiveRuntimeClientError::RuntimeUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ impl LiveRuntimeClient for FailingLiveRuntimeClient {
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
FAILING_ENSURE_COUNT.fetch_add(1, Ordering::SeqCst);
|
||||
Err(LiveRuntimeClientError::SidecarExited)
|
||||
Err(LiveRuntimeClientError::RuntimeUnavailable)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
@@ -392,7 +392,7 @@ fn recovery_client_factory(
|
||||
) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
let factory_call = RECOVERY_FACTORY_COUNT.fetch_add(1, Ordering::SeqCst);
|
||||
if factory_call == 0 {
|
||||
return Ok(Box::new(SidecarExitLiveRuntimeClient));
|
||||
return Ok(Box::new(RuntimeUnavailableLiveRuntimeClient));
|
||||
}
|
||||
Ok(Box::new(FakeLiveRuntimeClient))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_domain::TabId;
|
||||
use gpui::{Bounds, Pixels};
|
||||
use gpui::{Bounds, NativeSurfaceHandle, Pixels};
|
||||
|
||||
use super::{
|
||||
web_surface_cadence::ACTIVE_POLL_INTERVAL,
|
||||
@@ -126,6 +126,7 @@ pub(super) struct WebSurfaceTickResult {
|
||||
pub(super) struct PerTabSurface {
|
||||
pub(super) viewport_bounds: Option<Bounds<Pixels>>,
|
||||
pub(super) viewport_size: Option<WebSurfaceSize>,
|
||||
pub(super) native_surface: Option<NativeSurfaceHandle>,
|
||||
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
|
||||
pub(super) hover_point: Option<WebSurfaceClickPoint>,
|
||||
last_hover_enqueued_at: Option<Instant>,
|
||||
@@ -145,6 +146,7 @@ impl PerTabSurface {
|
||||
Self {
|
||||
viewport_bounds: None,
|
||||
viewport_size: None,
|
||||
native_surface: None,
|
||||
last_ensure_key: None,
|
||||
hover_point: None,
|
||||
last_hover_enqueued_at: None,
|
||||
@@ -228,6 +230,7 @@ const HOVER_INPUT_MIN_INTERVAL: Duration = Duration::from_millis(32);
|
||||
pub(super) struct WebSurfaceEnsureKey {
|
||||
requested_url: String,
|
||||
size: WebSurfaceSize,
|
||||
native_surface_id: Option<usize>,
|
||||
zoom_percent: u16,
|
||||
permissions: Vec<WebSurfaceSitePermission>,
|
||||
}
|
||||
@@ -236,10 +239,17 @@ impl WebSurfaceEnsureKey {
|
||||
pub(super) fn new(
|
||||
requested_url: String,
|
||||
size: WebSurfaceSize,
|
||||
native_surface: Option<&NativeSurfaceHandle>,
|
||||
zoom_percent: u16,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
) -> Self {
|
||||
Self { requested_url, size, zoom_percent, permissions: permissions.to_vec() }
|
||||
Self {
|
||||
requested_url,
|
||||
size,
|
||||
native_surface_id: native_surface.map(NativeSurfaceHandle::identity),
|
||||
zoom_percent,
|
||||
permissions: permissions.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +307,7 @@ mod tests {
|
||||
WebSurfaceEnsureKey::new(
|
||||
url.to_string(),
|
||||
WebSurfaceSize { width, height, device_pixel_ratio_percent: 100 },
|
||||
None,
|
||||
100,
|
||||
&[],
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ fn scroll_after_click_keeps_keyboard_focus_and_typed_text() -> Result<(), Box<dy
|
||||
assert_eq!(
|
||||
input.scroll_delta.map(|delta| (delta.x(), delta.y())),
|
||||
Some((0, 140)),
|
||||
"scroll delta should reach the sidecar",
|
||||
"scroll delta should reach the Servo runtime",
|
||||
);
|
||||
assert_eq!(input.scroll_offset.y(), 140);
|
||||
assert!(
|
||||
@@ -118,7 +118,7 @@ fn scroll_after_click_keeps_keyboard_focus_and_typed_text() -> Result<(), Box<dy
|
||||
assert_eq!(
|
||||
input.typed_text.as_deref(),
|
||||
Some("hi"),
|
||||
"buffered keystrokes from before AND after the scroll must reach the sidecar",
|
||||
"buffered keystrokes from before AND after the scroll must reach the Servo runtime",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -421,141 +421,7 @@ fn empty_live_frame_payload_is_rejected() -> Result<(), String> {
|
||||
Ok(_) => return Err("empty Servo frame payload reached Ready state".to_string()),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"servo live frame did not include a software image or hardware IOSurface",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn hardware_live_frame_with_pixel_buffer_skips_software_image() -> Result<(), String> {
|
||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
||||
|
||||
use crate::services::servo_live::ServoLiveFrame;
|
||||
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
||||
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
||||
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 1, 1, None)
|
||||
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
|
||||
let live = ServoLiveFrame::for_test_with_pixel_buffer(1, 1, pixel_buffer);
|
||||
let frame = WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
live,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
assert!(frame.image.is_none(), "hardware frame should use the CVPixelBuffer surface path");
|
||||
assert!(frame.pixel_buffer.is_some(), "hardware frame should carry the imported CVPixelBuffer");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn hardware_live_frame_rejects_mismatched_surface_size() -> Result<(), String> {
|
||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
||||
|
||||
use crate::services::servo_live::ServoLiveFrame;
|
||||
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
||||
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
||||
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 2, 1, None)
|
||||
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
|
||||
let live = ServoLiveFrame::for_test_with_pixel_buffer(1, 1, pixel_buffer);
|
||||
let result = WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
live,
|
||||
);
|
||||
|
||||
let error = match result {
|
||||
Ok(_) => return Err("mismatched hardware surface reached Ready state".to_string()),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.to_string(), "servo hardware surface size 2x1 did not match frame report 1x1",);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn hardware_live_frame_rejects_unsupported_surface_format() -> Result<(), String> {
|
||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange};
|
||||
|
||||
use crate::services::servo_live::ServoLiveFrame;
|
||||
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
||||
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
||||
|
||||
let pixel_buffer =
|
||||
CVPixelBuffer::new(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, 2, 2, None)
|
||||
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
|
||||
let live = ServoLiveFrame::for_test_with_pixel_buffer(2, 2, pixel_buffer);
|
||||
let result = WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
live,
|
||||
);
|
||||
|
||||
let error = match result {
|
||||
Ok(_) => return Err("unsupported hardware surface format reached Ready state".to_string()),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"servo hardware surface pixel format 0x34323066 is unsupported; expected 32BGRA",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "macos", feature = "live-site-smoke"))]
|
||||
#[test]
|
||||
fn hardware_live_frame_samples_bgra_surface_pixels() -> Result<(), String> {
|
||||
use core_video::{
|
||||
pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA},
|
||||
r#return::kCVReturnSuccess,
|
||||
};
|
||||
|
||||
use crate::services::servo_live::ServoLiveFrame;
|
||||
use crate::shell::web_surface_frame::WebSurfaceFrame;
|
||||
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
||||
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 2, 1, None)
|
||||
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
|
||||
let lock_status = pixel_buffer.lock_base_address(0);
|
||||
if lock_status != kCVReturnSuccess {
|
||||
return Err(format!("CVPixelBufferLockBaseAddress returned status {lock_status}"));
|
||||
}
|
||||
let bytes_per_row = pixel_buffer.get_bytes_per_row();
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
let base_address = pixel_buffer.get_base_address().cast::<u8>();
|
||||
let bytes = std::slice::from_raw_parts_mut(base_address, bytes_per_row);
|
||||
bytes[0..8].copy_from_slice(&[
|
||||
0, 0, 255, 255, // red in BGRA memory order
|
||||
255, 255, 255, 255,
|
||||
]);
|
||||
}
|
||||
let unlock_status = pixel_buffer.unlock_base_address(0);
|
||||
if unlock_status != kCVReturnSuccess {
|
||||
return Err(format!("CVPixelBufferUnlockBaseAddress returned status {unlock_status}"));
|
||||
}
|
||||
|
||||
let live = ServoLiveFrame::for_test_with_pixel_buffer(2, 1, pixel_buffer);
|
||||
let frame = WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
live,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
assert_eq!(frame.non_white_pixel_count(), 1);
|
||||
assert_eq!(frame.content_pixel_count(), 1);
|
||||
assert_ne!(frame.sample_hash(), 0);
|
||||
assert_eq!(error.to_string(), "servo live frame did not include renderable pixels",);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +1,28 @@
|
||||
use ely_domain::{BrowserTab, TabId};
|
||||
use gpui::{
|
||||
AnyElement, App, Corners, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton,
|
||||
ObjectFit, ParentElement, Styled, StyledImage, Window, canvas, div, img, px, rgb, surface,
|
||||
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, Styled, Window, canvas, div, native_surface, px, rgb,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ElyShell, web_surface_frame::WebSurfaceFrame,
|
||||
web_surface_geometry::servo_scroll_delta_from_wheel_delta,
|
||||
};
|
||||
use ely_design_system::{colors, spacing};
|
||||
use ely_design_system::colors;
|
||||
|
||||
pub(super) fn render_ready_web_surface(
|
||||
frame: &WebSurfaceFrame,
|
||||
_frame: &WebSurfaceFrame,
|
||||
tab: &BrowserTab,
|
||||
state_entity: Entity<ElyShell>,
|
||||
) -> AnyElement {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(pixel_buffer) = frame.pixel_buffer.as_ref() {
|
||||
return render_web_surface(
|
||||
tab,
|
||||
state_entity,
|
||||
surface(pixel_buffer.clone())
|
||||
.size_full()
|
||||
.corner_radii(web_surface_corner_radii())
|
||||
.object_fit(ObjectFit::Fill),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(image) = frame.image.as_ref() {
|
||||
return render_web_surface(
|
||||
tab,
|
||||
state_entity,
|
||||
img(ImageSource::Render(image.clone())).size_full().object_fit(ObjectFit::Fill),
|
||||
);
|
||||
}
|
||||
render_web_surface(
|
||||
tab,
|
||||
state_entity,
|
||||
error_page("Web surface frame did not include renderable pixels."),
|
||||
)
|
||||
render_web_surface(tab, state_entity.clone(), render_native_web_surface(tab, state_entity))
|
||||
}
|
||||
|
||||
pub(super) fn render_loading_web_surface(
|
||||
tab: &BrowserTab,
|
||||
state_entity: Entity<ElyShell>,
|
||||
) -> AnyElement {
|
||||
render_web_surface(tab, state_entity, div().size_full())
|
||||
render_web_surface(tab, state_entity.clone(), render_native_web_surface(tab, state_entity))
|
||||
}
|
||||
|
||||
pub(super) fn render_failed_web_surface(
|
||||
@@ -56,15 +33,6 @@ pub(super) fn render_failed_web_surface(
|
||||
render_web_surface(tab, state_entity, error_page(message))
|
||||
}
|
||||
|
||||
fn web_surface_corner_radii() -> Corners<gpui::Pixels> {
|
||||
Corners {
|
||||
top_left: px(0.0),
|
||||
top_right: px(0.0),
|
||||
bottom_right: px(spacing::RADIUS_CARD),
|
||||
bottom_left: px(spacing::RADIUS_CARD),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_page(message: &str) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
@@ -107,6 +75,18 @@ fn render_web_surface(
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_native_web_surface(tab: &BrowserTab, state_entity: Entity<ElyShell>) -> impl IntoElement {
|
||||
let tab_id = tab.id().clone();
|
||||
let element_id = ElementId::Name(format!("web-surface-{}", tab_id.as_str()).into());
|
||||
native_surface(element_id, move |surface, bounds, window: &mut Window, cx: &mut App| {
|
||||
let scale_factor = window.scale_factor();
|
||||
state_entity.update(cx, |shell, cx| {
|
||||
shell.record_external_web_surface(tab_id.clone(), bounds, scale_factor, surface, cx);
|
||||
});
|
||||
})
|
||||
.size_full()
|
||||
}
|
||||
|
||||
fn render_input_overlay(
|
||||
tab_id: TabId,
|
||||
url: String,
|
||||
|
||||
@@ -9,13 +9,12 @@ use crate::services::servo_live::{
|
||||
ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame,
|
||||
};
|
||||
|
||||
/// IPC surface for the per-profile Servo sidecar.
|
||||
/// Blocking surface for the embedded Servo runtime.
|
||||
///
|
||||
/// Production wraps [`ServoLiveClient`] directly; tests substitute a
|
||||
/// fake. The contract: every call is blocking and may run for tens of
|
||||
/// milliseconds. Implementations live on the worker thread, never the
|
||||
/// UI thread.
|
||||
pub(super) trait LiveRuntimeClient: Send {
|
||||
/// milliseconds. Implementations live on the worker thread.
|
||||
pub(super) trait LiveRuntimeClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
request: ServoLiveEnsureRequest,
|
||||
@@ -45,20 +44,20 @@ impl LiveRuntimeClient for ServoLiveClient {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum LiveRuntimeClientError {
|
||||
SidecarExited,
|
||||
RuntimeUnavailable,
|
||||
Message(String),
|
||||
}
|
||||
|
||||
impl LiveRuntimeClientError {
|
||||
pub(super) fn is_sidecar_exited(&self) -> bool {
|
||||
matches!(self, Self::SidecarExited)
|
||||
pub(super) fn is_runtime_unavailable(&self) -> bool {
|
||||
matches!(self, Self::RuntimeUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LiveRuntimeClientError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::SidecarExited => formatter.write_str("servo live sidecar exited"),
|
||||
Self::RuntimeUnavailable => formatter.write_str("servo live runtime is unavailable"),
|
||||
Self::Message(message) => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
@@ -72,8 +71,8 @@ impl From<String> for LiveRuntimeClientError {
|
||||
|
||||
impl From<ServoLiveError> for LiveRuntimeClientError {
|
||||
fn from(error: ServoLiveError) -> Self {
|
||||
if error.is_sidecar_process_unusable() {
|
||||
return Self::SidecarExited;
|
||||
if error.is_runtime_unavailable() {
|
||||
return Self::RuntimeUnavailable;
|
||||
}
|
||||
Self::Message(error.to_string())
|
||||
}
|
||||
@@ -89,7 +88,7 @@ impl From<io::Error> for LiveRuntimeClientError {
|
||||
pub(super) enum WorkerResponse {
|
||||
Frame { tab_id: String, frame: ServoLiveFrame },
|
||||
Failed { tab_id: String, message: String },
|
||||
SidecarExited,
|
||||
RuntimeUnavailable,
|
||||
}
|
||||
|
||||
enum WorkerRequest {
|
||||
@@ -116,9 +115,9 @@ struct WorkerQueue {
|
||||
/// Owns a [`LiveRuntimeClient`] on a dedicated OS thread and exposes
|
||||
/// a non-blocking API: submit ensure/poll/close, then drain responses.
|
||||
///
|
||||
/// The UI thread never blocks on Servo IPC. Submissions push into a
|
||||
/// The UI thread never blocks on Servo. Submissions push into a
|
||||
/// coalescing queue (latest request per tab wins). The worker thread
|
||||
/// drains the queue, runs the blocking IPC, and emits responses on a
|
||||
/// drains the queue, runs the blocking calls, and emits responses on a
|
||||
/// `std::sync::mpsc` channel that the UI thread reads with `try_recv`.
|
||||
pub(super) struct LiveRuntimeWorker {
|
||||
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
|
||||
@@ -127,7 +126,9 @@ pub(super) struct LiveRuntimeWorker {
|
||||
}
|
||||
|
||||
impl LiveRuntimeWorker {
|
||||
pub(super) fn new(client: Box<dyn LiveRuntimeClient>) -> Result<Self, String> {
|
||||
pub(super) fn new(
|
||||
client_factory: impl FnOnce() -> Result<Box<dyn LiveRuntimeClient>, String> + Send + 'static,
|
||||
) -> Result<Self, String> {
|
||||
let queue = Arc::new((
|
||||
Mutex::new(WorkerQueue {
|
||||
pending: BTreeMap::new(),
|
||||
@@ -138,13 +139,35 @@ impl LiveRuntimeWorker {
|
||||
Condvar::new(),
|
||||
));
|
||||
let (response_tx, response_rx) = mpsc::channel();
|
||||
let (init_tx, init_rx) = mpsc::channel();
|
||||
let queue_for_thread = queue.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("ely-servo-live".to_string())
|
||||
.name("ely-servo-runtime".to_string())
|
||||
.spawn(move || {
|
||||
let client = match client_factory() {
|
||||
Ok(client) => {
|
||||
let _ = init_tx.send(Ok(()));
|
||||
client
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = init_tx.send(Err(error));
|
||||
return;
|
||||
}
|
||||
};
|
||||
run_worker(client, queue_for_thread, response_tx);
|
||||
})
|
||||
.map_err(|error| format!("failed to spawn servo live worker thread: {error}"))?;
|
||||
match init_rx.recv() {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => {
|
||||
let _ = thread.join();
|
||||
return Err(error);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = thread.join();
|
||||
return Err(format!("servo live worker initialization failed: {error}"));
|
||||
}
|
||||
}
|
||||
Ok(Self { queue, response_rx, thread: Some(thread) })
|
||||
}
|
||||
|
||||
@@ -317,7 +340,7 @@ enum Work {
|
||||
}
|
||||
|
||||
/// Forward a single client result to the response channel. Returns
|
||||
/// `true` when the worker should exit (sidecar process died).
|
||||
/// `true` when the worker should exit.
|
||||
fn dispatch_result(
|
||||
response_tx: &mpsc::Sender<WorkerResponse>,
|
||||
tab_id: String,
|
||||
@@ -330,11 +353,11 @@ fn dispatch_result(
|
||||
}
|
||||
Ok(None) => false,
|
||||
Err(error) => {
|
||||
let exited = error.is_sidecar_exited();
|
||||
let unavailable = error.is_runtime_unavailable();
|
||||
let message = error.to_string();
|
||||
let _ = response_tx.send(WorkerResponse::Failed { tab_id, message });
|
||||
if exited {
|
||||
let _ = response_tx.send(WorkerResponse::SidecarExited);
|
||||
if unavailable {
|
||||
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
|
||||
@@ -7,41 +7,30 @@ rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
servo-engine = ["dep:dpi", "dep:euclid", "dep:serde", "dep:serde_json", "dep:servo", "dep:url"]
|
||||
hardware-render = [
|
||||
"servo-engine",
|
||||
"dep:gleam",
|
||||
"dep:glow",
|
||||
"dep:image",
|
||||
"dep:log",
|
||||
"dep:mach2",
|
||||
"dep:surfman",
|
||||
"dep:objc2-io-surface",
|
||||
servo-engine = [
|
||||
"dep:dpi",
|
||||
"dep:euclid",
|
||||
"dep:naga",
|
||||
"dep:raw-window-handle",
|
||||
"dep:rustls",
|
||||
"dep:serde",
|
||||
"dep:serde_json",
|
||||
"dep:servo",
|
||||
"dep:url",
|
||||
]
|
||||
|
||||
[[bin]]
|
||||
name = "ely_servo_sidecar"
|
||||
path = "src/bin/ely_servo_sidecar.rs"
|
||||
required-features = ["servo-engine"]
|
||||
|
||||
[dependencies]
|
||||
dpi = { workspace = true, optional = true }
|
||||
ely_domain = { path = "../ely_domain" }
|
||||
euclid = { version = "0.22", optional = true }
|
||||
gleam = { version = "0.15", optional = true }
|
||||
glow = { version = "0.16", optional = true }
|
||||
image = { workspace = true, optional = true }
|
||||
log = { version = "0.4", optional = true }
|
||||
naga = { version = "26.0.0", features = ["termcolor"], optional = true }
|
||||
raw-window-handle = { version = "0.6", optional = true }
|
||||
rustls = { version = "0.23.40", default-features = false, features = ["std", "aws_lc_rs"], optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
servo = { workspace = true, optional = true }
|
||||
surfman = { version = "0.11", optional = true }
|
||||
thiserror.workspace = true
|
||||
url = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
mach2 = { version = "0.6", optional = true }
|
||||
objc2-io-surface = { version = "0.3.2", optional = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
use std::{
|
||||
io::Write,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_domain::TabId;
|
||||
use ely_servo_host::{
|
||||
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest,
|
||||
PermissionRequest, ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize,
|
||||
SoftwareServoHost, TouchTapRequest, WebViewSnapshot, WebViewState,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
#[path = "ely_servo_sidecar/args.rs"]
|
||||
mod args;
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[path = "ely_servo_sidecar/iosurface_mach.rs"]
|
||||
mod iosurface_mach;
|
||||
#[path = "ely_servo_sidecar/live.rs"]
|
||||
mod live;
|
||||
#[path = "ely_servo_sidecar/live_output.rs"]
|
||||
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"]
|
||||
mod report;
|
||||
|
||||
use args::{SidecarCommand, SnapshotArgs};
|
||||
use report::{SnapshotInputChanges, SnapshotReport};
|
||||
|
||||
const WAIT_ITERATIONS: usize = 5_000;
|
||||
const WAIT_INTERVAL: Duration = Duration::from_millis(2);
|
||||
const RENDER_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const VISIBLE_FRAME_SETTLE_TIMEOUT: Duration = Duration::from_millis(700);
|
||||
const INPUT_SETTLE_TIMEOUT: Duration = Duration::from_millis(700);
|
||||
|
||||
fn main() -> Result<(), SidecarError> {
|
||||
match args::parse_env_command()? {
|
||||
SidecarCommand::Live(args) => live::run_live(args).map_err(SidecarError::Live),
|
||||
SidecarCommand::Snapshot(args) => run_snapshot(args),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum SidecarError {
|
||||
#[error("timed out rendering {url}: {snapshot:?}")]
|
||||
RenderTimeout { url: String, snapshot: Box<WebViewSnapshot> },
|
||||
|
||||
#[error(transparent)]
|
||||
Args(#[from] args::SidecarArgsError),
|
||||
|
||||
#[error(transparent)]
|
||||
Host(#[from] ServoHostError),
|
||||
|
||||
#[error(transparent)]
|
||||
Live(#[from] live::LiveSidecarError),
|
||||
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> {
|
||||
std::fs::create_dir_all(&args.profile_data_dir)?;
|
||||
let mut host = SoftwareServoHost::new_with_config_dir(
|
||||
ServoSurfaceSize::new(args.width, args.height),
|
||||
Some(args.profile_data_dir.clone()),
|
||||
)?;
|
||||
let tab_id = TabId::new();
|
||||
let webview_id = host.create_webview(tab_id.clone(), args.profile_id.clone())?;
|
||||
apply_site_permissions(&mut host, &webview_id, &args)?;
|
||||
host.set_page_zoom(PageZoomRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
zoom_factor: f32::from(args.page_zoom_percent) / 100.0,
|
||||
})?;
|
||||
|
||||
host.navigate(NavigationRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
tab_id,
|
||||
url: args.url.clone(),
|
||||
})?;
|
||||
|
||||
let snapshot = wait_for_frame(&mut host, &webview_id, args.url.as_str())?;
|
||||
let (snapshot, scroll_changed_frame) =
|
||||
apply_scroll_if_requested(&mut host, &webview_id, &args, snapshot)?;
|
||||
let (snapshot, click_changed_frame) =
|
||||
apply_click_if_requested(&mut host, &webview_id, &args, snapshot)?;
|
||||
let (snapshot, drag_changed_frame) =
|
||||
apply_drag_if_requested(&mut host, &webview_id, &args, snapshot)?;
|
||||
let (snapshot, touch_changed_frame) =
|
||||
apply_touch_if_requested(&mut host, &webview_id, &args, snapshot)?;
|
||||
let (snapshot, text_changed_frame) =
|
||||
apply_text_if_requested(&mut host, &webview_id, &args, snapshot)?;
|
||||
let frame = host.last_rendered_frame()?;
|
||||
std::fs::write(&args.rgba_out, frame.rgba_bytes())?;
|
||||
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
serde_json::to_writer(
|
||||
&mut stdout,
|
||||
&SnapshotReport::new(
|
||||
&args,
|
||||
&snapshot,
|
||||
&frame,
|
||||
SnapshotInputChanges {
|
||||
scroll: scroll_changed_frame,
|
||||
click: click_changed_frame,
|
||||
drag: drag_changed_frame,
|
||||
touch: touch_changed_frame,
|
||||
text: text_changed_frame,
|
||||
},
|
||||
),
|
||||
)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
stdout.flush()?;
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
fn apply_site_permissions(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
args: &SnapshotArgs,
|
||||
) -> Result<(), SidecarError> {
|
||||
for permission in &args.site_permissions {
|
||||
host.set_permission(
|
||||
PermissionRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
profile_id: args.profile_id.clone(),
|
||||
origin: permission.origin.clone(),
|
||||
feature: permission.feature,
|
||||
},
|
||||
permission.decision.into(),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_scroll_if_requested(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
args: &SnapshotArgs,
|
||||
snapshot: WebViewSnapshot,
|
||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
||||
if args.scroll_x == 0 && args.scroll_y == 0 {
|
||||
return Ok((snapshot, false));
|
||||
}
|
||||
|
||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
||||
host.scroll(ScrollRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
delta_x: args.scroll_x,
|
||||
delta_y: args.scroll_y,
|
||||
point_x: 0,
|
||||
point_y: 0,
|
||||
})?;
|
||||
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
|
||||
}
|
||||
|
||||
fn apply_click_if_requested(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
args: &SnapshotArgs,
|
||||
snapshot: WebViewSnapshot,
|
||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
||||
let Some(click_point) = args.click_point else {
|
||||
return Ok((snapshot, false));
|
||||
};
|
||||
|
||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
||||
host.click(MouseClickRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
x: click_point.x,
|
||||
y: click_point.y,
|
||||
})?;
|
||||
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
|
||||
}
|
||||
|
||||
fn apply_drag_if_requested(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
args: &SnapshotArgs,
|
||||
snapshot: WebViewSnapshot,
|
||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
||||
let Some(drag_points) = args.drag_points else {
|
||||
return Ok((snapshot, false));
|
||||
};
|
||||
|
||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
||||
host.drag(MouseDragRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
from_x: drag_points.from.x,
|
||||
from_y: drag_points.from.y,
|
||||
to_x: drag_points.to.x,
|
||||
to_y: drag_points.to.y,
|
||||
})?;
|
||||
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
|
||||
}
|
||||
|
||||
fn apply_touch_if_requested(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
args: &SnapshotArgs,
|
||||
snapshot: WebViewSnapshot,
|
||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
||||
let Some(touch_point) = args.touch_point else {
|
||||
return Ok((snapshot, false));
|
||||
};
|
||||
|
||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
||||
host.touch_tap(TouchTapRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
x: touch_point.x,
|
||||
y: touch_point.y,
|
||||
})?;
|
||||
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
|
||||
}
|
||||
|
||||
fn apply_text_if_requested(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
args: &SnapshotArgs,
|
||||
snapshot: WebViewSnapshot,
|
||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
||||
let Some(typed_text) = args.typed_text.as_ref() else {
|
||||
return Ok((snapshot, false));
|
||||
};
|
||||
|
||||
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
|
||||
host.type_text(KeyboardTextRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
text: typed_text.clone(),
|
||||
})?;
|
||||
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
|
||||
}
|
||||
|
||||
fn wait_for_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
url: &str,
|
||||
) -> Result<WebViewSnapshot, SidecarError> {
|
||||
let started_at = Instant::now();
|
||||
let mut latest_rendered_snapshot = None;
|
||||
let mut visible_frame_hash = None;
|
||||
let mut visible_frame_last_changed_at = None;
|
||||
|
||||
for _ in 0..WAIT_ITERATIONS {
|
||||
if started_at.elapsed() >= RENDER_TIMEOUT {
|
||||
break;
|
||||
}
|
||||
|
||||
host.tick();
|
||||
let snapshot = host.snapshot(webview_id)?;
|
||||
if snapshot.has_pending_frame() {
|
||||
host.paint(webview_id)?;
|
||||
}
|
||||
|
||||
let snapshot = host.snapshot(webview_id)?;
|
||||
if let Ok(frame) = host.last_rendered_frame()
|
||||
&& frame.non_white_pixel_count() > 0
|
||||
&& frame.content_pixel_count() > 0
|
||||
{
|
||||
let current_hash = frame.sample_hash();
|
||||
if visible_frame_hash != Some(current_hash) {
|
||||
visible_frame_hash = Some(current_hash);
|
||||
visible_frame_last_changed_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
if snapshot.state() == &WebViewState::Complete {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
|
||||
latest_rendered_snapshot = Some(snapshot.clone());
|
||||
if snapshot.url().is_some()
|
||||
&& visible_frame_last_changed_at
|
||||
.is_some_and(|changed_at| changed_at.elapsed() >= VISIBLE_FRAME_SETTLE_TIMEOUT)
|
||||
{
|
||||
return Ok(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(WAIT_INTERVAL);
|
||||
}
|
||||
|
||||
if let Some(snapshot) = latest_rendered_snapshot {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
|
||||
Err(SidecarError::RenderTimeout {
|
||||
url: url.to_string(),
|
||||
snapshot: Box::new(host.snapshot(webview_id)?),
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_changed_or_settled_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
previous_frame_hash: u64,
|
||||
) -> Result<(WebViewSnapshot, bool), SidecarError> {
|
||||
let started_at = Instant::now();
|
||||
let mut latest_snapshot = host.snapshot(webview_id)?;
|
||||
|
||||
for _ in 0..WAIT_ITERATIONS {
|
||||
if started_at.elapsed() >= INPUT_SETTLE_TIMEOUT {
|
||||
break;
|
||||
}
|
||||
|
||||
host.tick();
|
||||
let snapshot = host.snapshot(webview_id)?;
|
||||
if snapshot.has_pending_frame() {
|
||||
host.paint(webview_id)?;
|
||||
}
|
||||
|
||||
latest_snapshot = host.snapshot(webview_id)?;
|
||||
let changed_frame = host.last_rendered_frame().is_ok_and(|frame| {
|
||||
frame.non_white_pixel_count() > 0 && frame.sample_hash() != previous_frame_hash
|
||||
});
|
||||
if changed_frame {
|
||||
return Ok((latest_snapshot, true));
|
||||
}
|
||||
|
||||
thread::sleep(WAIT_INTERVAL);
|
||||
}
|
||||
|
||||
Ok((latest_snapshot, false))
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
use std::{env, num::ParseIntError, path::PathBuf};
|
||||
|
||||
use ely_domain::{
|
||||
DEFAULT_ZOOM_PERCENT, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature,
|
||||
UrlText, validate_zoom_percent,
|
||||
};
|
||||
use ely_servo_host::RenderingContextKind;
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
pub(super) enum SidecarCommand {
|
||||
Live(LiveArgs),
|
||||
Snapshot(SnapshotArgs),
|
||||
}
|
||||
|
||||
pub(super) struct LiveArgs {
|
||||
pub(super) profile_data_dir: PathBuf,
|
||||
pub(super) iosurface_mach_service: Option<String>,
|
||||
/// Rendering context the host's webviews are built against.
|
||||
/// Defaults to [`RenderingContextKind::Software`], which keeps
|
||||
/// the binary's behaviour bit-identical to pre-flag builds.
|
||||
/// `Hardware` is only accepted when the `hardware-render`
|
||||
/// feature is compiled in (otherwise the `SoftwareServoHost`
|
||||
/// constructor returns `HardwareRenderUnavailable`).
|
||||
pub(super) rendering_context_kind: RenderingContextKind,
|
||||
}
|
||||
|
||||
pub(super) struct SnapshotArgs {
|
||||
pub(super) url: UrlText,
|
||||
pub(super) profile_id: ProfileId,
|
||||
pub(super) profile_data_dir: PathBuf,
|
||||
pub(super) rgba_out: PathBuf,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
pub(super) scroll_x: i32,
|
||||
pub(super) scroll_y: i32,
|
||||
pub(super) page_zoom_percent: u16,
|
||||
pub(super) click_point: Option<ClickPoint>,
|
||||
pub(super) drag_points: Option<DragPoints>,
|
||||
pub(super) touch_point: Option<ClickPoint>,
|
||||
pub(super) typed_text: Option<String>,
|
||||
pub(super) site_permissions: Vec<SidecarSitePermission>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ClickPoint {
|
||||
pub(super) x: u32,
|
||||
pub(super) y: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct DragPoints {
|
||||
pub(super) from: ClickPoint,
|
||||
pub(super) to: ClickPoint,
|
||||
}
|
||||
|
||||
pub(super) struct SidecarSitePermission {
|
||||
pub(super) origin: SiteOrigin,
|
||||
pub(super) feature: SitePermissionFeature,
|
||||
pub(super) decision: SitePermissionDecision,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(super) enum SidecarArgsError {
|
||||
#[error("missing sidecar command")]
|
||||
MissingCommand,
|
||||
|
||||
#[error("unknown sidecar command: {value}")]
|
||||
UnknownCommand { value: String },
|
||||
|
||||
#[error("missing argument value for {name}")]
|
||||
MissingArgumentValue { name: &'static str },
|
||||
|
||||
#[error("missing required argument: {name}")]
|
||||
MissingRequiredArgument { name: &'static str },
|
||||
|
||||
#[error("unknown argument: {value}")]
|
||||
UnknownArgument { value: String },
|
||||
|
||||
#[error("{name} must be an integer: {value}")]
|
||||
InvalidInteger {
|
||||
name: &'static str,
|
||||
value: String,
|
||||
#[source]
|
||||
source: ParseIntError,
|
||||
},
|
||||
|
||||
#[error("{name} must be greater than zero")]
|
||||
ZeroDimension { name: &'static str },
|
||||
|
||||
#[error("--click-x and --click-y must be provided together")]
|
||||
IncompleteClickPoint,
|
||||
|
||||
#[error("--drag-from-x, --drag-from-y, --drag-to-x, and --drag-to-y must be provided together")]
|
||||
IncompleteDragPoints,
|
||||
|
||||
#[error("--touch-x and --touch-y must be provided together")]
|
||||
IncompleteTouchPoint,
|
||||
|
||||
#[error("{name} path is empty")]
|
||||
EmptyPath { name: &'static str },
|
||||
|
||||
#[error("invalid --site-permission JSON: {value}")]
|
||||
InvalidSitePermissionJson {
|
||||
value: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"invalid --rendering-context value: {value:?} (expected \"software\" or \
|
||||
\"hardware\")"
|
||||
)]
|
||||
InvalidRenderingContext { value: String },
|
||||
|
||||
#[error(transparent)]
|
||||
Domain(#[from] ely_domain::DomainError),
|
||||
}
|
||||
|
||||
pub(super) fn parse_env_command() -> Result<SidecarCommand, SidecarArgsError> {
|
||||
parse_command(env::args())
|
||||
}
|
||||
|
||||
fn parse_command(
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<SidecarCommand, SidecarArgsError> {
|
||||
let mut args = args.into_iter();
|
||||
let _program_name = args.next();
|
||||
let command = args.next().ok_or(SidecarArgsError::MissingCommand)?;
|
||||
|
||||
match command.as_str() {
|
||||
"live" => parse_live_args(args).map(SidecarCommand::Live),
|
||||
"snapshot" => parse_snapshot_args(args).map(SidecarCommand::Snapshot),
|
||||
_ => Err(SidecarArgsError::UnknownCommand { value: command }),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_live_args(args: impl IntoIterator<Item = String>) -> Result<LiveArgs, SidecarArgsError> {
|
||||
let mut args = args.into_iter();
|
||||
let mut profile_data_dir = None;
|
||||
let mut iosurface_mach_service = None;
|
||||
let mut rendering_context_kind = RenderingContextKind::default();
|
||||
|
||||
while let Some(name) = args.next() {
|
||||
match name.as_str() {
|
||||
"--profile-data-dir" => {
|
||||
profile_data_dir = Some(parse_path(
|
||||
"--profile-data-dir",
|
||||
next_argument(&mut args, "--profile-data-dir")?,
|
||||
)?)
|
||||
}
|
||||
"--rendering-context" => {
|
||||
let value = next_argument(&mut args, "--rendering-context")?;
|
||||
rendering_context_kind = match value.as_str() {
|
||||
"software" => RenderingContextKind::Software,
|
||||
"hardware" => RenderingContextKind::Hardware,
|
||||
_ => return Err(SidecarArgsError::InvalidRenderingContext { value }),
|
||||
};
|
||||
}
|
||||
"--iosurface-mach-service" => {
|
||||
iosurface_mach_service =
|
||||
Some(next_argument(&mut args, "--iosurface-mach-service")?);
|
||||
}
|
||||
_ => return Err(SidecarArgsError::UnknownArgument { value: name }),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LiveArgs {
|
||||
profile_data_dir: profile_data_dir
|
||||
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?,
|
||||
iosurface_mach_service,
|
||||
rendering_context_kind,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_snapshot_args(
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<SnapshotArgs, SidecarArgsError> {
|
||||
let mut args = args.into_iter();
|
||||
let mut url = None;
|
||||
let mut profile_id = None;
|
||||
let mut profile_data_dir = None;
|
||||
let mut rgba_out = None;
|
||||
let mut width = None;
|
||||
let mut height = None;
|
||||
let mut scroll_x = 0;
|
||||
let mut scroll_y = 0;
|
||||
let mut page_zoom_percent = DEFAULT_ZOOM_PERCENT;
|
||||
let mut click_x = None;
|
||||
let mut click_y = None;
|
||||
let mut drag_from_x = None;
|
||||
let mut drag_from_y = None;
|
||||
let mut drag_to_x = None;
|
||||
let mut drag_to_y = None;
|
||||
let mut touch_x = None;
|
||||
let mut touch_y = None;
|
||||
let mut typed_text = None;
|
||||
let mut site_permissions = Vec::new();
|
||||
|
||||
while let Some(name) = args.next() {
|
||||
match name.as_str() {
|
||||
"--url" => url = Some(UrlText::parse(next_argument(&mut args, "--url")?)?),
|
||||
"--profile-id" => {
|
||||
profile_id = Some(ProfileId::parse(next_argument(&mut args, "--profile-id")?)?)
|
||||
}
|
||||
"--profile-data-dir" => {
|
||||
profile_data_dir = Some(parse_path(
|
||||
"--profile-data-dir",
|
||||
next_argument(&mut args, "--profile-data-dir")?,
|
||||
)?)
|
||||
}
|
||||
"--rgba-out" => {
|
||||
rgba_out = Some(parse_path("--rgba-out", next_argument(&mut args, "--rgba-out")?)?)
|
||||
}
|
||||
"--width" => {
|
||||
width = Some(parse_dimension("--width", next_argument(&mut args, "--width")?)?)
|
||||
}
|
||||
"--height" => {
|
||||
height = Some(parse_dimension("--height", next_argument(&mut args, "--height")?)?)
|
||||
}
|
||||
"--scroll-x" => {
|
||||
scroll_x =
|
||||
parse_scroll_delta("--scroll-x", next_argument(&mut args, "--scroll-x")?)?
|
||||
}
|
||||
"--scroll-y" => {
|
||||
scroll_y =
|
||||
parse_scroll_delta("--scroll-y", next_argument(&mut args, "--scroll-y")?)?
|
||||
}
|
||||
"--page-zoom-percent" => {
|
||||
page_zoom_percent = parse_zoom_percent(
|
||||
"--page-zoom-percent",
|
||||
next_argument(&mut args, "--page-zoom-percent")?,
|
||||
)?
|
||||
}
|
||||
"--click-x" => {
|
||||
click_x = Some(parse_click_coordinate(
|
||||
"--click-x",
|
||||
next_argument(&mut args, "--click-x")?,
|
||||
)?)
|
||||
}
|
||||
"--click-y" => {
|
||||
click_y = Some(parse_click_coordinate(
|
||||
"--click-y",
|
||||
next_argument(&mut args, "--click-y")?,
|
||||
)?)
|
||||
}
|
||||
"--drag-from-x" => {
|
||||
drag_from_x = Some(parse_click_coordinate(
|
||||
"--drag-from-x",
|
||||
next_argument(&mut args, "--drag-from-x")?,
|
||||
)?)
|
||||
}
|
||||
"--drag-from-y" => {
|
||||
drag_from_y = Some(parse_click_coordinate(
|
||||
"--drag-from-y",
|
||||
next_argument(&mut args, "--drag-from-y")?,
|
||||
)?)
|
||||
}
|
||||
"--drag-to-x" => {
|
||||
drag_to_x = Some(parse_click_coordinate(
|
||||
"--drag-to-x",
|
||||
next_argument(&mut args, "--drag-to-x")?,
|
||||
)?)
|
||||
}
|
||||
"--drag-to-y" => {
|
||||
drag_to_y = Some(parse_click_coordinate(
|
||||
"--drag-to-y",
|
||||
next_argument(&mut args, "--drag-to-y")?,
|
||||
)?)
|
||||
}
|
||||
"--touch-x" => {
|
||||
touch_x = Some(parse_click_coordinate(
|
||||
"--touch-x",
|
||||
next_argument(&mut args, "--touch-x")?,
|
||||
)?)
|
||||
}
|
||||
"--touch-y" => {
|
||||
touch_y = Some(parse_click_coordinate(
|
||||
"--touch-y",
|
||||
next_argument(&mut args, "--touch-y")?,
|
||||
)?)
|
||||
}
|
||||
"--type-text" => typed_text = Some(next_argument(&mut args, "--type-text")?),
|
||||
"--site-permission" => site_permissions
|
||||
.push(parse_site_permission(next_argument(&mut args, "--site-permission")?)?),
|
||||
_ => return Err(SidecarArgsError::UnknownArgument { value: name }),
|
||||
}
|
||||
}
|
||||
|
||||
let click_point = match (click_x, click_y) {
|
||||
(Some(x), Some(y)) => Some(ClickPoint { x, y }),
|
||||
(None, None) => None,
|
||||
_ => return Err(SidecarArgsError::IncompleteClickPoint),
|
||||
};
|
||||
let drag_points = match (drag_from_x, drag_from_y, drag_to_x, drag_to_y) {
|
||||
(Some(from_x), Some(from_y), Some(to_x), Some(to_y)) => Some(DragPoints {
|
||||
from: ClickPoint { x: from_x, y: from_y },
|
||||
to: ClickPoint { x: to_x, y: to_y },
|
||||
}),
|
||||
(None, None, None, None) => None,
|
||||
_ => return Err(SidecarArgsError::IncompleteDragPoints),
|
||||
};
|
||||
let touch_point = match (touch_x, touch_y) {
|
||||
(Some(x), Some(y)) => Some(ClickPoint { x, y }),
|
||||
(None, None) => None,
|
||||
_ => return Err(SidecarArgsError::IncompleteTouchPoint),
|
||||
};
|
||||
|
||||
Ok(SnapshotArgs {
|
||||
url: url.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--url" })?,
|
||||
profile_id: profile_id
|
||||
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-id" })?,
|
||||
profile_data_dir: profile_data_dir
|
||||
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?,
|
||||
rgba_out: rgba_out
|
||||
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--rgba-out" })?,
|
||||
width: width.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--width" })?,
|
||||
height: height.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--height" })?,
|
||||
scroll_x,
|
||||
scroll_y,
|
||||
page_zoom_percent,
|
||||
click_point,
|
||||
drag_points,
|
||||
touch_point,
|
||||
typed_text,
|
||||
site_permissions,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SitePermissionArg {
|
||||
origin: String,
|
||||
feature: String,
|
||||
decision: String,
|
||||
}
|
||||
|
||||
fn parse_site_permission(value: String) -> Result<SidecarSitePermission, SidecarArgsError> {
|
||||
let parsed: SitePermissionArg = serde_json::from_str(&value)
|
||||
.map_err(|source| SidecarArgsError::InvalidSitePermissionJson { value, source })?;
|
||||
|
||||
Ok(SidecarSitePermission {
|
||||
origin: SiteOrigin::parse(parsed.origin)?,
|
||||
feature: SitePermissionFeature::parse(parsed.feature.as_str())?,
|
||||
decision: SitePermissionDecision::parse(parsed.decision.as_str())?,
|
||||
})
|
||||
}
|
||||
|
||||
fn next_argument(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
name: &'static str,
|
||||
) -> Result<String, SidecarArgsError> {
|
||||
args.next().ok_or(SidecarArgsError::MissingArgumentValue { name })
|
||||
}
|
||||
|
||||
fn parse_dimension(name: &'static str, value: String) -> Result<u32, SidecarArgsError> {
|
||||
let dimension = value.parse::<u32>().map_err(|source| SidecarArgsError::InvalidInteger {
|
||||
name,
|
||||
value,
|
||||
source,
|
||||
})?;
|
||||
if dimension == 0 {
|
||||
return Err(SidecarArgsError::ZeroDimension { name });
|
||||
}
|
||||
|
||||
Ok(dimension)
|
||||
}
|
||||
|
||||
fn parse_scroll_delta(name: &'static str, value: String) -> Result<i32, SidecarArgsError> {
|
||||
value.parse::<i32>().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source })
|
||||
}
|
||||
|
||||
fn parse_click_coordinate(name: &'static str, value: String) -> Result<u32, SidecarArgsError> {
|
||||
value.parse::<u32>().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source })
|
||||
}
|
||||
|
||||
fn parse_zoom_percent(name: &'static str, value: String) -> Result<u16, SidecarArgsError> {
|
||||
let percent = value.parse::<u16>().map_err(|source| SidecarArgsError::InvalidInteger {
|
||||
name,
|
||||
value,
|
||||
source,
|
||||
})?;
|
||||
Ok(validate_zoom_percent(percent)?)
|
||||
}
|
||||
|
||||
fn parse_path(name: &'static str, value: String) -> Result<PathBuf, SidecarArgsError> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(SidecarArgsError::EmptyPath { name });
|
||||
}
|
||||
|
||||
Ok(PathBuf::from(value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "args_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,183 +0,0 @@
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
use super::{SidecarArgsError, SidecarCommand, parse_command};
|
||||
use ely_domain::{
|
||||
DEFAULT_ZOOM_PERCENT, DomainError, ProfileId, SitePermissionDecision, SitePermissionFeature,
|
||||
};
|
||||
use ely_servo_host::RenderingContextKind;
|
||||
|
||||
#[test]
|
||||
fn parses_snapshot_profile_identity() -> Result<(), SidecarArgsError> {
|
||||
let profile_id = ProfileId::new();
|
||||
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
|
||||
let args = parse_snapshot_command(&profile_id, profile_data_dir.clone())?;
|
||||
|
||||
assert_eq!(args.profile_id, profile_id);
|
||||
assert_eq!(args.profile_data_dir, profile_data_dir);
|
||||
assert_eq!(args.page_zoom_percent, DEFAULT_ZOOM_PERCENT);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_snapshot_profile_id() {
|
||||
let command = parse_command(
|
||||
[
|
||||
"ely_servo_sidecar",
|
||||
"snapshot",
|
||||
"--url",
|
||||
"https://example.com",
|
||||
"--profile-id",
|
||||
"profile_invalid",
|
||||
"--profile-data-dir",
|
||||
"/tmp/profile",
|
||||
"--rgba-out",
|
||||
"/tmp/frame.rgba",
|
||||
"--width",
|
||||
"64",
|
||||
"--height",
|
||||
"64",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string),
|
||||
);
|
||||
|
||||
assert!(matches!(command, Err(SidecarArgsError::Domain(DomainError::InvalidEntityId { .. }))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_snapshot_site_permissions() -> Result<(), SidecarArgsError> {
|
||||
let profile_id = ProfileId::new();
|
||||
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
|
||||
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
|
||||
command.push("--site-permission".to_string());
|
||||
command.push(
|
||||
r#"{"origin":"https://example.com","feature":"camera","decision":"allow-once"}"#
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let args = snapshot_args(parse_command(command)?);
|
||||
assert_eq!(args.site_permissions.len(), 1);
|
||||
let permission = &args.site_permissions[0];
|
||||
assert_eq!(permission.origin.as_str(), "https://example.com");
|
||||
assert_eq!(permission.feature, SitePermissionFeature::Camera);
|
||||
assert_eq!(permission.decision, SitePermissionDecision::AllowOnce);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_snapshot_page_zoom_percent() -> Result<(), SidecarArgsError> {
|
||||
let profile_id = ProfileId::new();
|
||||
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
|
||||
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
|
||||
command.push("--page-zoom-percent".to_string());
|
||||
command.push("125".to_string());
|
||||
|
||||
let args = snapshot_args(parse_command(command)?);
|
||||
assert_eq!(args.page_zoom_percent, 125);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_snapshot_page_zoom_percent() {
|
||||
let profile_id = ProfileId::new();
|
||||
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
|
||||
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
|
||||
command.push("--page-zoom-percent".to_string());
|
||||
command.push("5".to_string());
|
||||
|
||||
assert!(matches!(
|
||||
parse_command(command),
|
||||
Err(SidecarArgsError::Domain(DomainError::InvalidZoomPercent { value: 5, .. }))
|
||||
));
|
||||
}
|
||||
|
||||
fn parse_snapshot_command(
|
||||
profile_id: &ProfileId,
|
||||
profile_data_dir: PathBuf,
|
||||
) -> Result<super::SnapshotArgs, SidecarArgsError> {
|
||||
Ok(snapshot_args(parse_command(snapshot_command_args(profile_id, profile_data_dir))?))
|
||||
}
|
||||
|
||||
fn snapshot_args(command: SidecarCommand) -> super::SnapshotArgs {
|
||||
match command {
|
||||
SidecarCommand::Snapshot(args) => args,
|
||||
SidecarCommand::Live(_) => unreachable!("expected snapshot command"),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_command_args(profile_id: &ProfileId, profile_data_dir: PathBuf) -> Vec<String> {
|
||||
[
|
||||
"ely_servo_sidecar".to_string(),
|
||||
"snapshot".to_string(),
|
||||
"--url".to_string(),
|
||||
"https://example.com".to_string(),
|
||||
"--profile-id".to_string(),
|
||||
profile_id.as_str().to_string(),
|
||||
"--profile-data-dir".to_string(),
|
||||
profile_data_dir.display().to_string(),
|
||||
"--rgba-out".to_string(),
|
||||
"/tmp/frame.rgba".to_string(),
|
||||
"--width".to_string(),
|
||||
"64".to_string(),
|
||||
"--height".to_string(),
|
||||
"64".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_live(extra_args: &[&str]) -> Result<super::LiveArgs, SidecarArgsError> {
|
||||
let base = ["ely_servo_sidecar", "live", "--profile-data-dir", "/tmp/sidecar-live"];
|
||||
let argv: Vec<String> =
|
||||
base.iter().chain(extra_args.iter()).map(|s| (*s).to_string()).collect();
|
||||
let SidecarCommand::Live(args) = parse_command(argv)? else {
|
||||
return Err(SidecarArgsError::UnknownCommand {
|
||||
value: "live-extracted-as-snapshot".into(),
|
||||
});
|
||||
};
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_defaults_rendering_context_to_software() -> Result<(), SidecarArgsError> {
|
||||
let args = parse_live(&[])?;
|
||||
assert_eq!(args.rendering_context_kind, RenderingContextKind::Software);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_accepts_explicit_software_rendering_context() -> Result<(), SidecarArgsError> {
|
||||
let args = parse_live(&["--rendering-context", "software"])?;
|
||||
assert_eq!(args.rendering_context_kind, RenderingContextKind::Software);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_accepts_explicit_hardware_rendering_context() -> Result<(), SidecarArgsError> {
|
||||
let args = parse_live(&["--rendering-context", "hardware"])?;
|
||||
assert_eq!(args.rendering_context_kind, RenderingContextKind::Hardware);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_accepts_iosurface_mach_service_name() -> Result<(), SidecarArgsError> {
|
||||
let args = parse_live(&["--iosurface-mach-service", "com.ely.test.iosurface"])?;
|
||||
assert_eq!(args.iosurface_mach_service.as_deref(), Some("com.ely.test.iosurface"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_rejects_unknown_rendering_context_value() {
|
||||
assert!(matches!(
|
||||
parse_live(&["--rendering-context", "gpu"]),
|
||||
Err(SidecarArgsError::InvalidRenderingContext { value }) if value == "gpu"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_requires_rendering_context_value() {
|
||||
assert!(matches!(
|
||||
parse_live(&["--rendering-context"]),
|
||||
Err(SidecarArgsError::MissingArgumentValue { name: "--rendering-context" })
|
||||
));
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
use std::{ffi::CString, mem, time::Duration};
|
||||
|
||||
use mach2::{
|
||||
bootstrap::{bootstrap_look_up, bootstrap_port},
|
||||
kern_return::KERN_SUCCESS,
|
||||
mach_port::mach_port_deallocate,
|
||||
message::{
|
||||
MACH_MSG_SUCCESS, MACH_MSG_TYPE_COPY_SEND, MACH_MSG_TYPE_MOVE_SEND, MACH_MSGH_BITS,
|
||||
MACH_MSGH_BITS_COMPLEX, MACH_SEND_MSG, MACH_SEND_TIMEOUT, mach_msg, mach_msg_body_t,
|
||||
mach_msg_header_t, mach_msg_port_descriptor_t,
|
||||
},
|
||||
port::{MACH_PORT_NULL, mach_port_t},
|
||||
traps::mach_task_self,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::live_protocol::{LiveOutcome, LiveSidecarError};
|
||||
|
||||
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
|
||||
const SEND_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
pub(super) struct IOSurfaceMachSender {
|
||||
send_port: mach_port_t,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(super) enum IOSurfaceMachError {
|
||||
#[error("Mach service name contains an interior nul byte")]
|
||||
InvalidServiceName,
|
||||
|
||||
#[error("bootstrap_look_up returned {code}")]
|
||||
LookupService { code: i32 },
|
||||
|
||||
#[error("mach_msg send returned {code}")]
|
||||
Send { code: i32 },
|
||||
}
|
||||
|
||||
impl IOSurfaceMachSender {
|
||||
pub(super) fn connect(service_name: &str) -> Result<Self, IOSurfaceMachError> {
|
||||
let service_name =
|
||||
CString::new(service_name).map_err(|_| IOSurfaceMachError::InvalidServiceName)?;
|
||||
let mut send_port = MACH_PORT_NULL;
|
||||
#[expect(unsafe_code)]
|
||||
let result =
|
||||
unsafe { bootstrap_look_up(bootstrap_port, service_name.as_ptr(), &mut send_port) };
|
||||
if result != KERN_SUCCESS {
|
||||
return Err(IOSurfaceMachError::LookupService { code: result });
|
||||
}
|
||||
Ok(Self { send_port })
|
||||
}
|
||||
|
||||
pub(super) fn send_surface_port(
|
||||
&mut self,
|
||||
surface_id: u64,
|
||||
mach_port: mach_port_t,
|
||||
) -> Result<(), IOSurfaceMachError> {
|
||||
let mut message = IOSurfacePortMessage {
|
||||
header: mach_msg_header_t {
|
||||
msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0) | MACH_MSGH_BITS_COMPLEX,
|
||||
msgh_size: mem::size_of::<IOSurfacePortMessage>() as u32,
|
||||
msgh_remote_port: self.send_port,
|
||||
msgh_local_port: MACH_PORT_NULL,
|
||||
msgh_voucher_port: MACH_PORT_NULL,
|
||||
msgh_id: IOSURFACE_PORT_MESSAGE_ID,
|
||||
},
|
||||
body: mach_msg_body_t { msgh_descriptor_count: 1 },
|
||||
surface_port: mach_msg_port_descriptor_t::new(mach_port, MACH_MSG_TYPE_MOVE_SEND),
|
||||
surface_id,
|
||||
};
|
||||
#[expect(unsafe_code)]
|
||||
let result = unsafe {
|
||||
mach_msg(
|
||||
&mut message.header,
|
||||
MACH_SEND_MSG | MACH_SEND_TIMEOUT,
|
||||
message.header.msgh_size,
|
||||
0,
|
||||
MACH_PORT_NULL,
|
||||
timeout_millis(SEND_TIMEOUT),
|
||||
MACH_PORT_NULL,
|
||||
)
|
||||
};
|
||||
if result != MACH_MSG_SUCCESS {
|
||||
destroy_message(&mut message);
|
||||
return Err(IOSurfaceMachError::Send { code: result });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IOSurfaceMachSender {
|
||||
fn drop(&mut self) {
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
let _ = mach_port_deallocate(task, self.send_port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn send_surface_port_if_needed(
|
||||
sender: Option<&mut IOSurfaceMachSender>,
|
||||
outcome: &mut Result<LiveOutcome, LiveSidecarError>,
|
||||
) {
|
||||
let (Some(sender), Ok(live_outcome)) = (sender, outcome.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
let Some(handle) = live_outcome.response.surface_handle else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = sender.send_surface_port(handle.surface_id, handle.mach_port_name) {
|
||||
*outcome = Err(LiveSidecarError::IOSurfaceMach(error));
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct IOSurfacePortMessage {
|
||||
header: mach_msg_header_t,
|
||||
body: mach_msg_body_t,
|
||||
surface_port: mach_msg_port_descriptor_t,
|
||||
surface_id: u64,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
io::{self, BufRead},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use ely_domain::{ProfileId, TabId, UrlText};
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
use ely_servo_host::ServoHostError;
|
||||
use ely_servo_host::{
|
||||
IOSurfaceIdentity, NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize,
|
||||
SoftwareServoHost,
|
||||
};
|
||||
|
||||
use super::args::LiveArgs;
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
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, PartialFrameTimings};
|
||||
use super::live_session::{
|
||||
LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, ensure_session,
|
||||
};
|
||||
use super::perf::{FramePerfAggregator, FramePerfSummary, elapsed_ns};
|
||||
|
||||
pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> {
|
||||
let LiveArgs { profile_data_dir, iosurface_mach_service, rendering_context_kind } = args;
|
||||
let publish_readback_surface_fields = true;
|
||||
let require_client_ready_surfaces = iosurface_mach_service.is_some();
|
||||
fs::create_dir_all(&profile_data_dir)?;
|
||||
let context_label = rendering_context_label(rendering_context_kind);
|
||||
let mut host = SoftwareServoHost::new_with_config_dir_and_kind(
|
||||
ServoSurfaceSize::new(1, 1),
|
||||
Some(profile_data_dir),
|
||||
rendering_context_kind,
|
||||
)?;
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
let mut iosurface_mach_sender =
|
||||
iosurface_mach_service.as_deref().map(IOSurfaceMachSender::connect).transpose()?;
|
||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
||||
let _ = iosurface_mach_service;
|
||||
let mut sessions = HashMap::new();
|
||||
let mut perf =
|
||||
FramePerfAggregator::new(context_label, FramePerfAggregator::DEFAULT_WINDOW_SIZE);
|
||||
let mut pending_summary: Option<FramePerfSummary> = None;
|
||||
let mut published_surface_ids: HashMap<String, HashSet<IOSurfaceIdentity>> = HashMap::new();
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout().lock();
|
||||
|
||||
for line in stdin.lock().lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// `frame_started_at` is the honest start of the end-to-end
|
||||
// frame: a request just arrived and we're about to do
|
||||
// everything required to put bytes back on the pipe. The
|
||||
// matching stop is the `stdout.flush()` inside
|
||||
// `write_outcome`.
|
||||
let frame_started_at = Instant::now();
|
||||
let outcome = match serde_json::from_str::<LiveRequest>(&line) {
|
||||
Ok(request) => handle_request(
|
||||
&mut host,
|
||||
&mut sessions,
|
||||
&mut published_surface_ids,
|
||||
rendering_context_kind,
|
||||
publish_readback_surface_fields,
|
||||
require_client_ready_surfaces,
|
||||
request,
|
||||
),
|
||||
Err(error) => Err(LiveSidecarError::Json(error)),
|
||||
};
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
let outcome = {
|
||||
let mut outcome = outcome;
|
||||
send_surface_port_if_needed(iosurface_mach_sender.as_mut(), &mut outcome);
|
||||
outcome
|
||||
};
|
||||
write_outcome(&mut stdout, &mut perf, &mut pending_summary, outcome, frame_started_at)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const fn rendering_context_label(kind: RenderingContextKind) -> &'static str {
|
||||
match kind {
|
||||
RenderingContextKind::Software => "software",
|
||||
RenderingContextKind::Hardware => "hardware",
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(
|
||||
host: &mut SoftwareServoHost,
|
||||
sessions: &mut HashMap<String, LiveSession>,
|
||||
published_surface_ids: &mut HashMap<String, HashSet<IOSurfaceIdentity>>,
|
||||
rendering_context_kind: RenderingContextKind,
|
||||
publish_readback_surface_fields: bool,
|
||||
require_client_ready_surfaces: bool,
|
||||
request: LiveRequest,
|
||||
) -> Result<LiveOutcome, LiveSidecarError> {
|
||||
match request {
|
||||
LiveRequest::Ensure {
|
||||
tab_id,
|
||||
profile_id,
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
page_zoom_percent,
|
||||
device_pixel_ratio,
|
||||
scroll_delta_x,
|
||||
scroll_delta_y,
|
||||
scroll_point_x,
|
||||
scroll_point_y,
|
||||
click_x,
|
||||
click_y,
|
||||
hover_x,
|
||||
hover_y,
|
||||
typed_text,
|
||||
site_permissions,
|
||||
ready_surface_ids,
|
||||
} => {
|
||||
let tab = TabId::parse(tab_id.clone())?;
|
||||
let profile = ProfileId::parse(profile_id)?;
|
||||
let url = UrlText::parse(url)?;
|
||||
let session =
|
||||
ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?;
|
||||
|
||||
if apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)? {
|
||||
session.awaiting_visible_frame = true;
|
||||
}
|
||||
apply_permissions(host, session, &profile, site_permissions)?;
|
||||
if session.requested_url != url.as_str() {
|
||||
host.navigate(NavigationRequest {
|
||||
webview_id: session.webview_id.clone(),
|
||||
tab_id: tab,
|
||||
url: url.clone(),
|
||||
})?;
|
||||
session.requested_url = url.as_str().to_string();
|
||||
session.scroll_x = 0;
|
||||
session.scroll_y = 0;
|
||||
session.awaiting_visible_frame = true;
|
||||
// New URL: the previous tab's pixels are no longer
|
||||
// valid evidence that "we have visible content"; let
|
||||
// the gate skip blank loading frames again.
|
||||
session.ever_visible_frame = false;
|
||||
}
|
||||
let input = LiveInput {
|
||||
scroll_delta_x,
|
||||
scroll_delta_y,
|
||||
scroll_point_x,
|
||||
scroll_point_y,
|
||||
click_x,
|
||||
click_y,
|
||||
hover_x,
|
||||
hover_y,
|
||||
typed_text,
|
||||
};
|
||||
if apply_input(host, session, input)? {
|
||||
// The app tick calls this sidecar synchronously from
|
||||
// GPUI's update path. Mark that a fresh frame is
|
||||
// desired, then let poll_frame take one event-loop
|
||||
// step; a later 16 ms app tick will poll again if
|
||||
// Servo has not painted yet.
|
||||
session.awaiting_visible_frame = true;
|
||||
}
|
||||
let webview_id = session.webview_id.clone();
|
||||
let mut outcome = poll_frame(
|
||||
host,
|
||||
session,
|
||||
rendering_context_kind,
|
||||
payloadless_readiness(
|
||||
&tab_id,
|
||||
published_surface_ids,
|
||||
&ready_surface_ids,
|
||||
require_client_ready_surfaces,
|
||||
),
|
||||
)?;
|
||||
populate_surface_fields(
|
||||
host,
|
||||
&webview_id,
|
||||
&tab_id,
|
||||
published_surface_ids,
|
||||
publish_readback_surface_fields,
|
||||
&mut outcome,
|
||||
);
|
||||
Ok(outcome)
|
||||
}
|
||||
LiveRequest::Poll { tab_id, ready_surface_ids } => {
|
||||
let Some(session) = sessions.get_mut(&tab_id) else {
|
||||
return Ok(LiveOutcome::empty());
|
||||
};
|
||||
let webview_id = session.webview_id.clone();
|
||||
let mut outcome = poll_frame(
|
||||
host,
|
||||
session,
|
||||
rendering_context_kind,
|
||||
payloadless_readiness(
|
||||
&tab_id,
|
||||
published_surface_ids,
|
||||
&ready_surface_ids,
|
||||
require_client_ready_surfaces,
|
||||
),
|
||||
)?;
|
||||
populate_surface_fields(
|
||||
host,
|
||||
&webview_id,
|
||||
&tab_id,
|
||||
published_surface_ids,
|
||||
publish_readback_surface_fields,
|
||||
&mut outcome,
|
||||
);
|
||||
Ok(outcome)
|
||||
}
|
||||
LiveRequest::Close { tab_id } => {
|
||||
if let Some(session) = sessions.remove(&tab_id) {
|
||||
host.close_webview(&session.webview_id);
|
||||
}
|
||||
published_surface_ids.remove(&tab_id);
|
||||
Ok(LiveOutcome::empty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &mut LiveSession,
|
||||
rendering_context_kind: RenderingContextKind,
|
||||
readiness: PayloadlessReadiness<'_>,
|
||||
) -> Result<LiveOutcome, LiveSidecarError> {
|
||||
host.tick();
|
||||
let snapshot = host.snapshot(&session.webview_id)?;
|
||||
let has_pending_frame = snapshot.has_pending_frame();
|
||||
if !should_paint_live_frame(has_pending_frame, session.awaiting_visible_frame) {
|
||||
return Ok(LiveOutcome::empty());
|
||||
}
|
||||
|
||||
let (outcome, has_visible_content) =
|
||||
paint_pending_frame(host, session, rendering_context_kind, readiness, has_pending_frame)?;
|
||||
if has_visible_content {
|
||||
session.awaiting_visible_frame = false;
|
||||
session.ever_visible_frame = true;
|
||||
return Ok(outcome);
|
||||
}
|
||||
if !session.awaiting_visible_frame {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
Ok(LiveOutcome::empty())
|
||||
}
|
||||
|
||||
fn should_paint_live_frame(has_pending_frame: bool, awaiting_visible_frame: bool) -> bool {
|
||||
has_pending_frame || awaiting_visible_frame
|
||||
}
|
||||
|
||||
fn paint_pending_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &mut LiveSession,
|
||||
rendering_context_kind: RenderingContextKind,
|
||||
readiness: PayloadlessReadiness<'_>,
|
||||
has_pending_frame: bool,
|
||||
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
|
||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
||||
let _ = readiness;
|
||||
|
||||
match rendering_context_kind {
|
||||
RenderingContextKind::Software => paint_readback_frame(host, session, !has_pending_frame),
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
RenderingContextKind::Hardware => {
|
||||
paint_hardware_surface_frame(host, session, readiness, has_pending_frame)
|
||||
}
|
||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
||||
RenderingContextKind::Hardware => paint_readback_frame(host, session, !has_pending_frame),
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_readback_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &LiveSession,
|
||||
wait_for_completion: bool,
|
||||
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
|
||||
let paint_started_at = Instant::now();
|
||||
host.paint_with_readback(&session.webview_id, wait_for_completion)?;
|
||||
let snapshot = host.snapshot(&session.webview_id)?;
|
||||
let frame = host.last_rendered_frame()?;
|
||||
let paint_ns = elapsed_ns(paint_started_at);
|
||||
let encode_started_at = Instant::now();
|
||||
let has_visible_content = session.ever_visible_frame
|
||||
|| (frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0);
|
||||
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio());
|
||||
let encode_ns = elapsed_ns(encode_started_at);
|
||||
let timings = PartialFrameTimings { paint_ns, encode_ns };
|
||||
Ok((LiveOutcome::from_frame(report, frame, timings), has_visible_content))
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
fn paint_hardware_surface_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &LiveSession,
|
||||
readiness: PayloadlessReadiness<'_>,
|
||||
has_pending_frame: bool,
|
||||
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
|
||||
if !session.ever_visible_frame {
|
||||
return paint_initial_hardware_surface_frame(host, session, !has_pending_frame);
|
||||
}
|
||||
if !payloadless_surface_pool_ready(readiness, session.width, session.height) {
|
||||
return paint_readback_frame(host, session, !has_pending_frame);
|
||||
}
|
||||
let (outcome, _) = paint_hardware_surface_report(host, session, !has_pending_frame)?;
|
||||
Ok((outcome, true))
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
fn paint_initial_hardware_surface_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &LiveSession,
|
||||
wait_for_completion: bool,
|
||||
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
|
||||
let paint_started_at = Instant::now();
|
||||
host.paint_with_readback(&session.webview_id, wait_for_completion)?;
|
||||
let snapshot = host.snapshot(&session.webview_id)?;
|
||||
let frame = host.last_rendered_frame()?;
|
||||
let paint_ns = elapsed_ns(paint_started_at);
|
||||
let encode_started_at = Instant::now();
|
||||
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio());
|
||||
let has_visible_content = frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0;
|
||||
let encode_ns = elapsed_ns(encode_started_at);
|
||||
let timings = PartialFrameTimings { paint_ns, encode_ns };
|
||||
Ok((LiveOutcome::from_frame(report, frame, timings), has_visible_content))
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
fn paint_hardware_surface_report(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &LiveSession,
|
||||
wait_for_completion: bool,
|
||||
) -> Result<(LiveOutcome, IOSurfaceIdentity), LiveSidecarError> {
|
||||
let paint_started_at = Instant::now();
|
||||
host.paint_without_readback_with_completion(&session.webview_id, wait_for_completion)?;
|
||||
let snapshot = host.snapshot(&session.webview_id)?;
|
||||
let identity = host.peek_iosurface_identity(&session.webview_id)?.ok_or_else(|| {
|
||||
ServoHostError::HardwareSurfaceUnavailable { id: session.webview_id.clone() }
|
||||
})?;
|
||||
let paint_ns = elapsed_ns(paint_started_at);
|
||||
let encode_started_at = Instant::now();
|
||||
let report = LiveFrameReport::from_surface(
|
||||
&snapshot,
|
||||
identity.width,
|
||||
identity.height,
|
||||
session.device_pixel_ratio(),
|
||||
);
|
||||
let encode_ns = elapsed_ns(encode_started_at);
|
||||
let timings = PartialFrameTimings { paint_ns, encode_ns };
|
||||
Ok((LiveOutcome::from_report(report, timings), identity))
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
fn payloadless_surface_pool_ready(
|
||||
readiness: PayloadlessReadiness<'_>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> bool {
|
||||
let Some(published) = readiness.published_surface_ids.get(readiness.tab_id) else {
|
||||
return false;
|
||||
};
|
||||
let matching = published
|
||||
.iter()
|
||||
.filter(|identity| identity.width == width && identity.height == height)
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
if matching.is_empty() {
|
||||
return false;
|
||||
}
|
||||
!readiness.require_client_ready_surfaces
|
||||
|| matching
|
||||
.iter()
|
||||
.any(|identity| readiness.ready_surface_ids.contains(&identity.surface_id))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PayloadlessReadiness<'a> {
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
tab_id: &'a str,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
ready_surface_ids: &'a [u64],
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
require_client_ready_surfaces: bool,
|
||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
||||
_marker: std::marker::PhantomData<&'a ()>,
|
||||
}
|
||||
|
||||
fn payloadless_readiness<'a>(
|
||||
tab_id: &'a str,
|
||||
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
|
||||
ready_surface_ids: &'a [u64],
|
||||
require_client_ready_surfaces: bool,
|
||||
) -> PayloadlessReadiness<'a> {
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
{
|
||||
PayloadlessReadiness {
|
||||
tab_id,
|
||||
published_surface_ids,
|
||||
ready_surface_ids,
|
||||
require_client_ready_surfaces,
|
||||
}
|
||||
}
|
||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
||||
{
|
||||
let _ = (tab_id, published_surface_ids, ready_surface_ids, require_client_ready_surfaces);
|
||||
PayloadlessReadiness { _marker: std::marker::PhantomData }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn awaiting_visible_frame_forces_paint_without_pending_flag() {
|
||||
assert!(should_paint_live_frame(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_poll_waits_for_pending_frame() {
|
||||
assert!(!should_paint_live_frame(false, false));
|
||||
assert!(should_paint_live_frame(true, false));
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[test]
|
||||
fn payloadless_pool_accepts_one_client_ready_surface() {
|
||||
let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]);
|
||||
|
||||
assert!(!payloadless_surface_pool_ready(readiness(&published, &[], true), 800, 600));
|
||||
assert!(payloadless_surface_pool_ready(readiness(&published, &[7], true), 800, 600));
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[test]
|
||||
fn payloadless_pool_uses_published_surfaces_for_no_mach_clients() {
|
||||
let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]);
|
||||
|
||||
assert!(payloadless_surface_pool_ready(readiness(&published, &[], false), 800, 600));
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
fn readiness<'a>(
|
||||
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
|
||||
ready_surface_ids: &'a [u64],
|
||||
require_client_ready_surfaces: bool,
|
||||
) -> PayloadlessReadiness<'a> {
|
||||
PayloadlessReadiness {
|
||||
tab_id: "tab",
|
||||
published_surface_ids,
|
||||
ready_surface_ids,
|
||||
require_client_ready_surfaces,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
fn published_identities(
|
||||
identities: [IOSurfaceIdentity; 2],
|
||||
) -> HashMap<String, HashSet<IOSurfaceIdentity>> {
|
||||
let mut published = HashMap::new();
|
||||
published.insert("tab".to_string(), identities.into_iter().collect());
|
||||
published
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
fn identity(surface_id: u64, width: u32, height: u32) -> IOSurfaceIdentity {
|
||||
IOSurfaceIdentity { surface_id, width, height }
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
io::Write,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
||||
use ely_servo_host::IOSurfaceHandle;
|
||||
use ely_servo_host::{IOSurfaceIdentity, SoftwareServoHost};
|
||||
|
||||
use super::live_protocol::{LiveOutcome, LiveSidecarError, PartialFrameTimings};
|
||||
use super::perf::{FramePerfAggregator, FramePerfSummary, FrameStageTimings, elapsed_ns};
|
||||
|
||||
/// Populate the hardware surface protocol fields on `outcome`. Readback
|
||||
/// warm-up frames publish IOSurface handles so the app can import them
|
||||
/// on its dedicated importer thread before steady-state payloadless
|
||||
/// frames select the rotating surface ids. Two pieces of state ride out
|
||||
/// together:
|
||||
///
|
||||
/// * `current_surface_id` — set on every payload-bearing hardware
|
||||
/// frame so the receiver knows which previously-imported
|
||||
/// `MTLTexture` to sample THIS frame. surfman's attached swap
|
||||
/// chain rotates front/back surfaces, so this alternates between
|
||||
/// a small set of ids.
|
||||
/// * `surface_handle` — populated only the first time the sidecar
|
||||
/// sees a given `surface_id`; the receiver imports the IOSurface
|
||||
/// once and caches the resulting Metal texture. Minting a fresh
|
||||
/// mach port per frame would leak ports — `IOSurfaceCreateMachPort`
|
||||
/// hands out a new send right each call and they don't free
|
||||
/// automatically until the receiver `mach_port_deallocate`s.
|
||||
pub(super) fn populate_surface_fields(
|
||||
host: &SoftwareServoHost,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
tab_id: &str,
|
||||
published_surface_ids: &mut HashMap<String, HashSet<IOSurfaceIdentity>>,
|
||||
publish_readback_surface_fields: bool,
|
||||
outcome: &mut LiveOutcome,
|
||||
) {
|
||||
if outcome.response.frame.is_none() {
|
||||
return;
|
||||
}
|
||||
if outcome.frame.is_some() && !publish_readback_surface_fields {
|
||||
return;
|
||||
}
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
{
|
||||
let Ok(Some(identity)) = host.peek_iosurface_identity(webview_id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(message) = require_report_matches_surface_identity(outcome, identity) {
|
||||
*outcome = LiveOutcome::error(message);
|
||||
return;
|
||||
}
|
||||
let handle = if surface_has_been_published(published_surface_ids, tab_id, identity) {
|
||||
None
|
||||
} else {
|
||||
host.current_iosurface_handle(webview_id).ok().flatten()
|
||||
};
|
||||
let publication = surface_publication_for(published_surface_ids, tab_id, identity, handle);
|
||||
outcome.response.current_surface_id = publication.current_surface_id;
|
||||
outcome.response.surface_handle = publication.surface_handle;
|
||||
}
|
||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
||||
{
|
||||
let _ = (host, webview_id, tab_id, published_surface_ids, publish_readback_surface_fields);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
||||
fn surface_has_been_published(
|
||||
published_surface_ids: &HashMap<String, HashSet<IOSurfaceIdentity>>,
|
||||
tab_id: &str,
|
||||
identity: IOSurfaceIdentity,
|
||||
) -> bool {
|
||||
published_surface_ids.get(tab_id).is_some_and(|published| published.contains(&identity))
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
||||
#[derive(Clone, Copy)]
|
||||
struct SurfacePublication {
|
||||
current_surface_id: Option<u64>,
|
||||
surface_handle: Option<IOSurfaceHandle>,
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
||||
fn surface_publication_for(
|
||||
published_surface_ids: &mut HashMap<String, HashSet<IOSurfaceIdentity>>,
|
||||
tab_id: &str,
|
||||
identity: IOSurfaceIdentity,
|
||||
handle: Option<IOSurfaceHandle>,
|
||||
) -> SurfacePublication {
|
||||
if surface_has_been_published(published_surface_ids, tab_id, identity) {
|
||||
return SurfacePublication {
|
||||
current_surface_id: Some(identity.surface_id),
|
||||
surface_handle: None,
|
||||
};
|
||||
}
|
||||
|
||||
let Some(handle) = handle.filter(|handle| handle_matches_identity(*handle, identity)) else {
|
||||
return SurfacePublication { current_surface_id: None, surface_handle: None };
|
||||
};
|
||||
|
||||
published_surface_ids
|
||||
.entry(tab_id.to_string())
|
||||
.or_default()
|
||||
.insert(IOSurfaceIdentity::from_handle(handle));
|
||||
SurfacePublication { current_surface_id: Some(handle.surface_id), surface_handle: Some(handle) }
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
||||
fn handle_matches_identity(handle: IOSurfaceHandle, identity: IOSurfaceIdentity) -> bool {
|
||||
handle.surface_id == identity.surface_id
|
||||
&& handle.width == identity.width
|
||||
&& handle.height == identity.height
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
||||
fn require_report_matches_surface_identity(
|
||||
outcome: &LiveOutcome,
|
||||
identity: IOSurfaceIdentity,
|
||||
) -> Result<(), String> {
|
||||
let Some(frame) = outcome.response.frame.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if frame.width == identity.width && frame.height == identity.height {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"servo hardware surface size {}x{} did not match frame report {}x{}",
|
||||
identity.width, identity.height, frame.width, frame.height,
|
||||
))
|
||||
}
|
||||
|
||||
/// Serialise the response then stream the optional raw RGBA frame on
|
||||
/// the same stdout pipe. The client reads the JSON line, takes
|
||||
/// `rgba_byte_count` from the report, then reads that many bytes
|
||||
/// from the same stream — no temp file round-trip.
|
||||
///
|
||||
/// After the bytes hit the pipe we fold paint/encode/write/total
|
||||
/// timings into the aggregator. `total_ns` is the wall-clock span
|
||||
/// from `frame_started_at` (request arrival) to the stdout flush
|
||||
/// returning, so it captures every per-frame cost outside the three
|
||||
/// measured stages. Any summary the aggregator emits is stashed on
|
||||
/// `pending_summary` and rides out on the *next* response, because
|
||||
/// the protocol is one-line-per-response and an unsolicited summary
|
||||
/// line would desync the main process's read loop.
|
||||
pub(super) fn write_outcome(
|
||||
stdout: &mut impl Write,
|
||||
perf: &mut FramePerfAggregator,
|
||||
pending_summary: &mut Option<FramePerfSummary>,
|
||||
outcome: Result<LiveOutcome, LiveSidecarError>,
|
||||
frame_started_at: Instant,
|
||||
) -> Result<(), LiveSidecarError> {
|
||||
let mut outcome = outcome.unwrap_or_else(|error| LiveOutcome::error(error.to_string()));
|
||||
let partial_timings = outcome.partial_timings.take();
|
||||
let frame_present = outcome.response.frame.is_some();
|
||||
if let Some(summary) = pending_summary.take() {
|
||||
outcome.response.perf = Some(summary);
|
||||
}
|
||||
// Payloadless hardware frames carry only the IOSurface selector.
|
||||
let drop_rgba_payload =
|
||||
outcome.response.current_surface_id.is_some() && outcome.frame.is_none();
|
||||
if drop_rgba_payload && let Some(report) = outcome.response.frame.as_mut() {
|
||||
report.rgba_byte_count = 0;
|
||||
}
|
||||
let write_started_at = Instant::now();
|
||||
serde_json::to_writer(&mut *stdout, &outcome.response)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
if !drop_rgba_payload && let Some(frame) = outcome.frame.as_ref() {
|
||||
stdout.write_all(frame.rgba_bytes())?;
|
||||
}
|
||||
stdout.flush()?;
|
||||
if frame_present {
|
||||
let write_ns = elapsed_ns(write_started_at);
|
||||
let total_ns = elapsed_ns(frame_started_at);
|
||||
let partial = partial_timings.unwrap_or(PartialFrameTimings { paint_ns: 0, encode_ns: 0 });
|
||||
let timings = FrameStageTimings::from_durations(
|
||||
Duration::from_nanos(partial.paint_ns),
|
||||
Duration::from_nanos(partial.encode_ns),
|
||||
Duration::from_nanos(write_ns),
|
||||
Duration::from_nanos(total_ns),
|
||||
);
|
||||
if let Some(summary) = perf.record(timings) {
|
||||
*pending_summary = Some(summary);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{collections::HashMap, error::Error, time::Instant};
|
||||
|
||||
use ely_servo_host::{IOSurfaceHandle, IOSurfaceIdentity};
|
||||
|
||||
use super::super::{
|
||||
live_protocol::{LiveFrameReport, LiveOutcome, PartialFrameTimings},
|
||||
perf::FramePerfAggregator,
|
||||
};
|
||||
use super::{require_report_matches_surface_identity, surface_publication_for, write_outcome};
|
||||
|
||||
#[test]
|
||||
fn unpublished_surface_without_handle_leaves_selector_empty() {
|
||||
let mut published = HashMap::new();
|
||||
let publication =
|
||||
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), None);
|
||||
|
||||
assert_eq!(publication.current_surface_id, None);
|
||||
assert!(publication.surface_handle.is_none());
|
||||
assert!(published.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpublished_surface_with_matching_handle_publishes_selector_and_handle() {
|
||||
let mut published = HashMap::new();
|
||||
let handle = handle(7, 800, 600);
|
||||
let publication =
|
||||
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(handle));
|
||||
|
||||
assert_eq!(publication.current_surface_id, Some(7));
|
||||
assert_eq!(publication.surface_handle, Some(handle));
|
||||
assert!(published.get("tab-1").is_some_and(|ids| ids.contains(&identity(7, 800, 600))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_surface_reuses_selector_without_republishing_handle() {
|
||||
let mut published = HashMap::new();
|
||||
let handle = handle(7, 800, 600);
|
||||
let _ =
|
||||
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(handle));
|
||||
let publication =
|
||||
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), None);
|
||||
|
||||
assert_eq!(publication.current_surface_id, Some(7));
|
||||
assert!(publication.surface_handle.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_surface_id_with_changed_dimensions_republishes_handle() {
|
||||
let mut published = HashMap::new();
|
||||
let initial = handle(7, 800, 600);
|
||||
let resized = handle(7, 1024, 768);
|
||||
|
||||
let _ =
|
||||
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(initial));
|
||||
let publication =
|
||||
surface_publication_for(&mut published, "tab-1", identity(7, 1024, 768), Some(resized));
|
||||
|
||||
assert_eq!(publication.current_surface_id, Some(7));
|
||||
assert_eq!(publication.surface_handle, Some(resized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_report_mismatch_is_reported() -> Result<(), Box<dyn Error>> {
|
||||
let outcome = LiveOutcome::from_report(
|
||||
report_with_size(2180, 1586),
|
||||
PartialFrameTimings { paint_ns: 1_000, encode_ns: 2_000 },
|
||||
);
|
||||
|
||||
let error = match require_report_matches_surface_identity(&outcome, identity(7, 2168, 1566))
|
||||
{
|
||||
Ok(()) => return Err("mismatched IOSurface dimensions must be reported".into()),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
"servo hardware surface size 2168x1566 did not match frame report 2180x1586",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_handle_leaves_surface_unpublished() {
|
||||
let mut published = HashMap::new();
|
||||
let publication = surface_publication_for(
|
||||
&mut published,
|
||||
"tab-1",
|
||||
identity(7, 800, 600),
|
||||
Some(handle(8, 800, 600)),
|
||||
);
|
||||
|
||||
assert_eq!(publication.current_surface_id, None);
|
||||
assert!(publication.surface_handle.is_none());
|
||||
assert!(published.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payloadless_surface_report_records_perf_and_writes_no_rgba() -> Result<(), Box<dyn Error>> {
|
||||
let mut outcome = LiveOutcome::from_report(
|
||||
report_with_byte_count(16),
|
||||
PartialFrameTimings { paint_ns: 1_000, encode_ns: 2_000 },
|
||||
);
|
||||
outcome.response.current_surface_id = Some(7);
|
||||
let mut stdout = Vec::new();
|
||||
let mut perf = FramePerfAggregator::new("hardware", 1);
|
||||
let mut pending_summary = None;
|
||||
|
||||
write_outcome(&mut stdout, &mut perf, &mut pending_summary, Ok(outcome), Instant::now())?;
|
||||
|
||||
let Some(newline_index) = stdout.iter().position(|byte| *byte == b'\n') else {
|
||||
return Err("response newline missing".into());
|
||||
};
|
||||
let line = std::str::from_utf8(&stdout[..newline_index])?;
|
||||
let response: serde_json::Value = serde_json::from_str(line)?;
|
||||
let rgba_byte_count = response
|
||||
.get("frame")
|
||||
.and_then(|frame| frame.get("rgba_byte_count"))
|
||||
.and_then(serde_json::Value::as_u64);
|
||||
|
||||
assert_eq!(rgba_byte_count, Some(0));
|
||||
assert!(stdout[newline_index + 1..].is_empty());
|
||||
assert!(pending_summary.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn identity(surface_id: u64, width: u32, height: u32) -> IOSurfaceIdentity {
|
||||
IOSurfaceIdentity { surface_id, width, height }
|
||||
}
|
||||
|
||||
fn handle(surface_id: u64, width: u32, height: u32) -> IOSurfaceHandle {
|
||||
IOSurfaceHandle { mach_port_name: 42, surface_id, width, height }
|
||||
}
|
||||
|
||||
fn report_with_byte_count(rgba_byte_count: usize) -> LiveFrameReport {
|
||||
let mut report = report_with_size(2, 2);
|
||||
report.rgba_byte_count = rgba_byte_count;
|
||||
report
|
||||
}
|
||||
|
||||
fn report_with_size(width: u32, height: u32) -> LiveFrameReport {
|
||||
LiveFrameReport {
|
||||
loaded_url: Some("https://example.com/".to_string()),
|
||||
title: Some("Example".to_string()),
|
||||
state: "complete",
|
||||
width,
|
||||
height,
|
||||
device_pixel_ratio: 1.0,
|
||||
css_viewport_width: width,
|
||||
css_viewport_height: height,
|
||||
rgba_byte_count: 0,
|
||||
non_white_pixel_count: 0,
|
||||
content_pixel_count: 0,
|
||||
sample_hash: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
//! Wire types for the sidecar live loop. Split out of `live.rs` to
|
||||
//! keep the hot loop and protocol surface in separate files.
|
||||
|
||||
use std::io;
|
||||
|
||||
use ely_servo_host::{
|
||||
IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
use super::iosurface_mach::IOSurfaceMachError;
|
||||
use super::perf::FramePerfSummary;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(super) enum LiveRequest {
|
||||
Ensure {
|
||||
tab_id: String,
|
||||
profile_id: String,
|
||||
url: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
page_zoom_percent: u16,
|
||||
/// Display scale factor reported by the host's window
|
||||
/// (1.0 standard, 2.0 Retina). The sidecar plumbs this into
|
||||
/// Servo's `WebView::set_hidpi_scale_factor` so CSS layout
|
||||
/// happens at logical-pixel dimensions instead of physical.
|
||||
/// Defaults to 1.0 for backward compatibility if a client
|
||||
/// (e.g. the live perf bench) omits the field.
|
||||
#[serde(default = "default_device_pixel_ratio")]
|
||||
device_pixel_ratio: f32,
|
||||
scroll_delta_x: i32,
|
||||
scroll_delta_y: i32,
|
||||
scroll_point_x: Option<u32>,
|
||||
scroll_point_y: Option<u32>,
|
||||
click_x: Option<u32>,
|
||||
click_y: Option<u32>,
|
||||
#[serde(default)]
|
||||
hover_x: Option<u32>,
|
||||
#[serde(default)]
|
||||
hover_y: Option<u32>,
|
||||
typed_text: Option<String>,
|
||||
site_permissions: Vec<LiveSitePermission>,
|
||||
#[serde(default)]
|
||||
ready_surface_ids: Vec<u64>,
|
||||
},
|
||||
Poll {
|
||||
tab_id: String,
|
||||
#[serde(default)]
|
||||
ready_surface_ids: Vec<u64>,
|
||||
},
|
||||
Close {
|
||||
tab_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_device_pixel_ratio() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LiveSitePermission {
|
||||
pub origin: String,
|
||||
pub feature: String,
|
||||
pub decision: String,
|
||||
}
|
||||
|
||||
/// Partial stage timings captured inside `poll_frame` before the
|
||||
/// write phase. Combined with the write-stage duration measured by
|
||||
/// `write_outcome` to form a full set of frame timings.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct PartialFrameTimings {
|
||||
pub paint_ns: u64,
|
||||
pub encode_ns: u64,
|
||||
}
|
||||
|
||||
/// A response plus an optional software RGBA payload and partial stage
|
||||
/// timings. Software frames carry `RenderedFrame` so the write step
|
||||
/// can stream its existing rgba slice straight onto the pipe; hardware
|
||||
/// surface frames carry only a `LiveFrameReport`.
|
||||
pub(super) struct LiveOutcome {
|
||||
pub response: LiveResponse,
|
||||
pub frame: Option<RenderedFrame>,
|
||||
pub partial_timings: Option<PartialFrameTimings>,
|
||||
}
|
||||
|
||||
impl LiveOutcome {
|
||||
pub fn empty() -> Self {
|
||||
Self { response: LiveResponse::empty(), frame: None, partial_timings: None }
|
||||
}
|
||||
|
||||
pub fn error(message: String) -> Self {
|
||||
Self { response: LiveResponse::error(message), frame: None, partial_timings: None }
|
||||
}
|
||||
|
||||
pub fn from_frame(
|
||||
report: LiveFrameReport,
|
||||
frame: RenderedFrame,
|
||||
partial_timings: PartialFrameTimings,
|
||||
) -> Self {
|
||||
Self {
|
||||
response: LiveResponse::frame(report),
|
||||
frame: Some(frame),
|
||||
partial_timings: Some(partial_timings),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
||||
pub fn from_report(report: LiveFrameReport, partial_timings: PartialFrameTimings) -> Self {
|
||||
Self {
|
||||
response: LiveResponse::frame(report),
|
||||
frame: None,
|
||||
partial_timings: Some(partial_timings),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct LiveResponse {
|
||||
pub error: Option<String>,
|
||||
pub frame: Option<LiveFrameReport>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub perf: Option<FramePerfSummary>,
|
||||
/// Populated on the first frame the sidecar emits for a given
|
||||
/// surface — initial paint, after a resize, or whenever surfman
|
||||
/// rotates its swap chain to a surface we haven't seen yet. The
|
||||
/// receiver imports the IOSurface (via
|
||||
/// `IOSurfaceLookupFromMachPort`) once per `surface_id` and caches
|
||||
/// the resulting Metal texture. Always `None` on the software
|
||||
/// path.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub surface_handle: Option<IOSurfaceHandle>,
|
||||
/// Populated on every hardware paint frame. Tells the receiver
|
||||
/// which previously-imported IOSurface to sample THIS frame. The
|
||||
/// surfman attached swap chain rotates between front/back
|
||||
/// surfaces, so this id alternates between the values the receiver
|
||||
/// has already imported. Always `None` on the software path.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl LiveResponse {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
error: None,
|
||||
frame: None,
|
||||
perf: None,
|
||||
surface_handle: None,
|
||||
current_surface_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(frame: LiveFrameReport) -> Self {
|
||||
Self {
|
||||
error: None,
|
||||
frame: Some(frame),
|
||||
perf: None,
|
||||
surface_handle: None,
|
||||
current_surface_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn error(message: String) -> Self {
|
||||
Self {
|
||||
error: Some(message),
|
||||
frame: None,
|
||||
perf: None,
|
||||
surface_handle: None,
|
||||
current_surface_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct LiveFrameReport {
|
||||
pub loaded_url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub state: &'static str,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub device_pixel_ratio: f32,
|
||||
pub css_viewport_width: u32,
|
||||
pub css_viewport_height: u32,
|
||||
pub rgba_byte_count: usize,
|
||||
pub non_white_pixel_count: u64,
|
||||
pub content_pixel_count: u64,
|
||||
pub sample_hash: u64,
|
||||
}
|
||||
|
||||
impl LiveFrameReport {
|
||||
pub fn new(snapshot: &WebViewSnapshot, frame: &RenderedFrame, device_pixel_ratio: f32) -> Self {
|
||||
let (css_viewport_width, css_viewport_height) =
|
||||
css_viewport_size(frame.width(), frame.height(), device_pixel_ratio);
|
||||
Self {
|
||||
loaded_url: snapshot.url().map(str::to_string),
|
||||
title: snapshot.title().map(str::to_string),
|
||||
state: state_label(snapshot.state()),
|
||||
width: frame.width(),
|
||||
height: frame.height(),
|
||||
device_pixel_ratio,
|
||||
css_viewport_width,
|
||||
css_viewport_height,
|
||||
rgba_byte_count: frame.rgba_bytes().len(),
|
||||
non_white_pixel_count: frame.non_white_pixel_count(),
|
||||
content_pixel_count: frame.content_pixel_count(),
|
||||
sample_hash: frame.sample_hash(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
pub fn from_surface(
|
||||
snapshot: &WebViewSnapshot,
|
||||
width: u32,
|
||||
height: u32,
|
||||
device_pixel_ratio: f32,
|
||||
) -> Self {
|
||||
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),
|
||||
state: state_label(snapshot.state()),
|
||||
width,
|
||||
height,
|
||||
device_pixel_ratio,
|
||||
css_viewport_width,
|
||||
css_viewport_height,
|
||||
rgba_byte_count: 0,
|
||||
non_white_pixel_count: 0,
|
||||
content_pixel_count: 0,
|
||||
sample_hash: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) {
|
||||
let dpr = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 {
|
||||
device_pixel_ratio
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
(
|
||||
((width as f32) / dpr).round().max(1.0) as u32,
|
||||
((height as f32) / dpr).round().max(1.0) as u32,
|
||||
)
|
||||
}
|
||||
|
||||
fn state_label(state: &WebViewState) -> &'static str {
|
||||
match state {
|
||||
WebViewState::Created => "created",
|
||||
WebViewState::Loading => "loading",
|
||||
WebViewState::Complete => "complete",
|
||||
WebViewState::Sleeping => "sleeping",
|
||||
WebViewState::Crashed => "crashed",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(super) enum LiveSidecarError {
|
||||
#[error("live session is unavailable after creation")]
|
||||
SessionUnavailable,
|
||||
|
||||
#[error("scroll input requires both scroll_point_x and scroll_point_y")]
|
||||
IncompleteScrollPoint,
|
||||
|
||||
#[error(transparent)]
|
||||
Domain(#[from] ely_domain::DomainError),
|
||||
|
||||
#[error(transparent)]
|
||||
Host(#[from] ServoHostError),
|
||||
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[error(transparent)]
|
||||
IOSurfaceMach(#[from] IOSurfaceMachError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn close_request_deserializes_from_wire() -> Result<(), serde_json::Error> {
|
||||
let request =
|
||||
serde_json::from_str::<LiveRequest>(r#"{"type":"close","tab_id":"tab-live-close"}"#)?;
|
||||
|
||||
assert!(matches!(
|
||||
request,
|
||||
LiveRequest::Close { tab_id } if tab_id == "tab-live-close"
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
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<String, LiveSession>,
|
||||
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<bool, LiveSidecarError> {
|
||||
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<LiveSitePermission>,
|
||||
) -> 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<bool, LiveSidecarError> {
|
||||
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<u32>,
|
||||
pub(super) scroll_point_y: Option<u32>,
|
||||
pub(super) click_x: Option<u32>,
|
||||
pub(super) click_y: Option<u32>,
|
||||
pub(super) hover_x: Option<u32>,
|
||||
pub(super) hover_y: Option<u32>,
|
||||
pub(super) typed_text: Option<String>,
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
//! Per-frame paint→encode→write stage timings for the live sidecar
|
||||
//! loop, plus exact fixed-window percentile summaries so the main
|
||||
//! process can read out p50/p95/p99 latencies.
|
||||
//!
|
||||
//! Why this lives next to `live.rs`: the sidecar already owns the hot
|
||||
//! loop. Sampling here costs one `Instant::now()` per stage boundary.
|
||||
//! Each stage keeps one preallocated window of nanosecond samples and
|
||||
//! sorts only at the 60-frame summary boundary, so steady-state record
|
||||
//! cost stays a single push per stage while p95 remains exact enough
|
||||
//! for 120 fps gates.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Per-frame stage timings captured by the live loop.
|
||||
///
|
||||
/// `total_ns` is the real wall-clock span from request arrival to the
|
||||
/// stdout flush returning, so it captures every byte of overhead
|
||||
/// outside paint/encode/write (snapshot reads, JSON parse, scratch
|
||||
/// allocations). It is measured at the loop boundary, not summed.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct FrameStageTimings {
|
||||
pub paint_ns: u64,
|
||||
pub encode_ns: u64,
|
||||
pub write_ns: u64,
|
||||
pub total_ns: u64,
|
||||
}
|
||||
|
||||
impl FrameStageTimings {
|
||||
pub(super) fn from_durations(
|
||||
paint: Duration,
|
||||
encode: Duration,
|
||||
write: Duration,
|
||||
total: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
paint_ns: duration_to_ns(paint),
|
||||
encode_ns: duration_to_ns(encode),
|
||||
write_ns: duration_to_ns(write),
|
||||
total_ns: duration_to_ns(total),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_to_ns(duration: Duration) -> u64 {
|
||||
u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Saturating elapsed-ns helper. `Instant::elapsed` is monotonic but
|
||||
/// the cast can still overflow on the (impossible) hour-long frame.
|
||||
pub(super) fn elapsed_ns(start: Instant) -> u64 {
|
||||
duration_to_ns(start.elapsed())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StageSamples {
|
||||
values: Vec<u64>,
|
||||
}
|
||||
|
||||
impl StageSamples {
|
||||
fn new(window_size: usize) -> Self {
|
||||
Self { values: Vec::with_capacity(window_size) }
|
||||
}
|
||||
|
||||
fn record(&mut self, ns: u64) {
|
||||
self.values.push(ns);
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.values.len()
|
||||
}
|
||||
|
||||
fn percentiles_us(&self) -> StagePercentiles {
|
||||
let mut sorted = self.values.clone();
|
||||
sorted.sort_unstable();
|
||||
StagePercentiles {
|
||||
p50: percentile_us(&sorted, 0.50),
|
||||
p95: percentile_us(&sorted, 0.95),
|
||||
p99: percentile_us(&sorted, 0.99),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.values.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct StagePercentiles {
|
||||
p50: u64,
|
||||
p95: u64,
|
||||
p99: u64,
|
||||
}
|
||||
|
||||
fn percentile_us(sorted_ns: &[u64], percentile: f64) -> u64 {
|
||||
if sorted_ns.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let target = ((sorted_ns.len() as f64) * percentile).ceil() as usize;
|
||||
let index = target.max(1).min(sorted_ns.len()) - 1;
|
||||
ns_to_us_ceil(sorted_ns[index])
|
||||
}
|
||||
|
||||
fn ns_to_us_ceil(ns: u64) -> u64 {
|
||||
ns.div_ceil(1_000)
|
||||
}
|
||||
|
||||
/// Aggregates a rolling window of [`FrameStageTimings`] across N
|
||||
/// frames, exposing one [`FramePerfSummary`] per window flush.
|
||||
pub(super) struct FramePerfAggregator {
|
||||
window_size: usize,
|
||||
paint: StageSamples,
|
||||
encode: StageSamples,
|
||||
write: StageSamples,
|
||||
total: StageSamples,
|
||||
context_label: &'static str,
|
||||
}
|
||||
|
||||
impl FramePerfAggregator {
|
||||
pub(super) const DEFAULT_WINDOW_SIZE: u32 = 60;
|
||||
|
||||
pub(super) fn new(context_label: &'static str, window_size: u32) -> Self {
|
||||
let window_size = usize::try_from(window_size.max(1)).unwrap_or(usize::MAX);
|
||||
Self {
|
||||
window_size,
|
||||
paint: StageSamples::new(window_size),
|
||||
encode: StageSamples::new(window_size),
|
||||
write: StageSamples::new(window_size),
|
||||
total: StageSamples::new(window_size),
|
||||
context_label,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record(&mut self, timings: FrameStageTimings) -> Option<FramePerfSummary> {
|
||||
self.paint.record(timings.paint_ns);
|
||||
self.encode.record(timings.encode_ns);
|
||||
self.write.record(timings.write_ns);
|
||||
self.total.record(timings.total_ns);
|
||||
if self.paint.len() < self.window_size {
|
||||
return None;
|
||||
}
|
||||
let paint = self.paint.percentiles_us();
|
||||
let encode = self.encode.percentiles_us();
|
||||
let write = self.write.percentiles_us();
|
||||
let total = self.total.percentiles_us();
|
||||
let summary = FramePerfSummary {
|
||||
window: u32::try_from(self.paint.len()).unwrap_or(u32::MAX),
|
||||
context: self.context_label,
|
||||
paint_p50_us: paint.p50,
|
||||
paint_p95_us: paint.p95,
|
||||
paint_p99_us: paint.p99,
|
||||
encode_p50_us: encode.p50,
|
||||
encode_p95_us: encode.p95,
|
||||
encode_p99_us: encode.p99,
|
||||
write_p50_us: write.p50,
|
||||
write_p95_us: write.p95,
|
||||
write_p99_us: write.p99,
|
||||
total_p50_us: total.p50,
|
||||
total_p95_us: total.p95,
|
||||
total_p99_us: total.p99,
|
||||
};
|
||||
self.paint.reset();
|
||||
self.encode.reset();
|
||||
self.write.reset();
|
||||
self.total.reset();
|
||||
Some(summary)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Serialize)]
|
||||
pub(super) struct FramePerfSummary {
|
||||
pub window: u32,
|
||||
pub context: &'static str,
|
||||
pub paint_p50_us: u64,
|
||||
pub paint_p95_us: u64,
|
||||
pub paint_p99_us: u64,
|
||||
pub encode_p50_us: u64,
|
||||
pub encode_p95_us: u64,
|
||||
pub encode_p99_us: u64,
|
||||
pub write_p50_us: u64,
|
||||
pub write_p95_us: u64,
|
||||
pub write_p99_us: u64,
|
||||
pub total_p50_us: u64,
|
||||
pub total_p95_us: u64,
|
||||
pub total_p99_us: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FramePerfAggregator, FrameStageTimings, percentile_us};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn percentile_us_uses_nearest_rank_and_ceils_microseconds() {
|
||||
let sorted_ns = [1, 1_000, 1_001];
|
||||
assert_eq!(percentile_us(&sorted_ns, 0.50), 1);
|
||||
assert_eq!(percentile_us(&sorted_ns, 0.95), 2);
|
||||
assert_eq!(percentile_us(&sorted_ns, 0.99), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregator_emits_summary_after_window_size_records() -> Result<(), &'static str> {
|
||||
let mut aggregator =
|
||||
FramePerfAggregator::new("software", FramePerfAggregator::DEFAULT_WINDOW_SIZE);
|
||||
for index in 0..(FramePerfAggregator::DEFAULT_WINDOW_SIZE - 1) {
|
||||
let result = aggregator.record(constant_timing());
|
||||
assert!(result.is_none(), "should not flush at frame {index}");
|
||||
}
|
||||
let summary = aggregator
|
||||
.record(constant_timing())
|
||||
.ok_or("aggregator must flush at window boundary")?;
|
||||
assert_eq!(summary.window, FramePerfAggregator::DEFAULT_WINDOW_SIZE);
|
||||
assert_eq!(summary.context, "software");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregator_resets_after_flush_so_next_window_starts_fresh() {
|
||||
let mut aggregator = FramePerfAggregator::new("hardware", 2);
|
||||
let _ = aggregator.record(constant_timing());
|
||||
let summary = aggregator.record(constant_timing());
|
||||
assert!(summary.is_some(), "expected first flush");
|
||||
let after_flush = aggregator.record(constant_timing());
|
||||
assert!(after_flush.is_none(), "aggregator must zero counters after flush");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregator_percentiles_track_increasing_paint_durations() -> Result<(), &'static str> {
|
||||
let mut aggregator = FramePerfAggregator::new("software", 4);
|
||||
let paint_durations_us = [10u64, 100, 1_000, 10_000];
|
||||
let mut summary = None;
|
||||
for paint_us in paint_durations_us {
|
||||
summary = aggregator.record(FrameStageTimings::from_durations(
|
||||
Duration::from_micros(paint_us),
|
||||
Duration::from_micros(1),
|
||||
Duration::from_micros(1),
|
||||
Duration::from_micros(paint_us + 2),
|
||||
));
|
||||
}
|
||||
let summary = summary.ok_or("4-frame window must flush")?;
|
||||
assert_eq!(summary.paint_p50_us, 100);
|
||||
assert_eq!(summary.paint_p95_us, 10_000);
|
||||
assert_eq!(summary.paint_p99_us, 10_000);
|
||||
assert_eq!(summary.total_p50_us, 102);
|
||||
assert_eq!(summary.total_p95_us, 10_002);
|
||||
assert_eq!(summary.total_p99_us, 10_002);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn constant_timing() -> FrameStageTimings {
|
||||
FrameStageTimings::from_durations(
|
||||
Duration::from_micros(2_000),
|
||||
Duration::from_micros(500),
|
||||
Duration::from_micros(100),
|
||||
Duration::from_micros(2_600),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
use ely_servo_host::{RenderedFrame, WebViewSnapshot, WebViewState};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::args::SnapshotArgs;
|
||||
|
||||
pub(super) struct SnapshotInputChanges {
|
||||
pub(super) scroll: bool,
|
||||
pub(super) click: bool,
|
||||
pub(super) drag: bool,
|
||||
pub(super) touch: bool,
|
||||
pub(super) text: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct SnapshotReport {
|
||||
requested_url: String,
|
||||
profile_id: String,
|
||||
loaded_url: Option<String>,
|
||||
title: Option<String>,
|
||||
rgba_path: String,
|
||||
state: &'static str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba_byte_count: usize,
|
||||
opaque_pixel_count: u64,
|
||||
non_white_pixel_count: u64,
|
||||
content_pixel_count: u64,
|
||||
sample_hash: u64,
|
||||
scroll_x: i32,
|
||||
scroll_y: i32,
|
||||
page_zoom_percent: u16,
|
||||
scroll_changed_frame: bool,
|
||||
click_x: Option<u32>,
|
||||
click_y: Option<u32>,
|
||||
click_changed_frame: bool,
|
||||
drag_from_x: Option<u32>,
|
||||
drag_from_y: Option<u32>,
|
||||
drag_to_x: Option<u32>,
|
||||
drag_to_y: Option<u32>,
|
||||
drag_changed_frame: bool,
|
||||
touch_x: Option<u32>,
|
||||
touch_y: Option<u32>,
|
||||
touch_changed_frame: bool,
|
||||
typed_text_byte_count: usize,
|
||||
text_changed_frame: bool,
|
||||
}
|
||||
|
||||
impl SnapshotReport {
|
||||
pub(super) fn new(
|
||||
args: &SnapshotArgs,
|
||||
snapshot: &WebViewSnapshot,
|
||||
frame: &RenderedFrame,
|
||||
changes: SnapshotInputChanges,
|
||||
) -> Self {
|
||||
Self {
|
||||
requested_url: args.url.as_str().to_string(),
|
||||
profile_id: snapshot.profile_id().as_str().to_string(),
|
||||
loaded_url: snapshot.url().map(str::to_string),
|
||||
title: snapshot.title().map(str::to_string),
|
||||
rgba_path: args.rgba_out.display().to_string(),
|
||||
state: state_label(snapshot.state()),
|
||||
width: frame.width(),
|
||||
height: frame.height(),
|
||||
rgba_byte_count: frame.rgba_bytes().len(),
|
||||
opaque_pixel_count: frame.opaque_pixel_count(),
|
||||
non_white_pixel_count: frame.non_white_pixel_count(),
|
||||
content_pixel_count: frame.content_pixel_count(),
|
||||
sample_hash: frame.sample_hash(),
|
||||
scroll_x: args.scroll_x,
|
||||
scroll_y: args.scroll_y,
|
||||
page_zoom_percent: args.page_zoom_percent,
|
||||
scroll_changed_frame: changes.scroll,
|
||||
click_x: args.click_point.map(|point| point.x),
|
||||
click_y: args.click_point.map(|point| point.y),
|
||||
click_changed_frame: changes.click,
|
||||
drag_from_x: args.drag_points.map(|points| points.from.x),
|
||||
drag_from_y: args.drag_points.map(|points| points.from.y),
|
||||
drag_to_x: args.drag_points.map(|points| points.to.x),
|
||||
drag_to_y: args.drag_points.map(|points| points.to.y),
|
||||
drag_changed_frame: changes.drag,
|
||||
touch_x: args.touch_point.map(|point| point.x),
|
||||
touch_y: args.touch_point.map(|point| point.y),
|
||||
touch_changed_frame: changes.touch,
|
||||
typed_text_byte_count: args.typed_text.as_ref().map_or(0, String::len),
|
||||
text_changed_frame: changes.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_label(state: &WebViewState) -> &'static str {
|
||||
match state {
|
||||
WebViewState::Created => "created",
|
||||
WebViewState::Loading => "loading",
|
||||
WebViewState::Complete => "complete",
|
||||
WebViewState::Sleeping => "sleeping",
|
||||
WebViewState::Crashed => "crashed",
|
||||
}
|
||||
}
|
||||
@@ -24,21 +24,6 @@ pub enum ServoHostError {
|
||||
#[error("servo rendering context could not be made current")]
|
||||
RenderingContextNotCurrent,
|
||||
|
||||
#[error(
|
||||
"hardware rendering context requested but the `hardware-render` feature \
|
||||
was not compiled in; rebuild with --features servo-engine,hardware-render"
|
||||
)]
|
||||
HardwareRenderUnavailable,
|
||||
|
||||
#[error("servo rendered frame is unavailable")]
|
||||
RenderedFrameUnavailable,
|
||||
|
||||
#[error("servo hardware surface is unavailable for {id}")]
|
||||
HardwareSurfaceUnavailable { id: WebViewId },
|
||||
|
||||
#[error("servo screenshot capture timed out for {id}")]
|
||||
ScreenshotTimedOut { id: WebViewId },
|
||||
|
||||
#[error("servo screenshot capture failed: {reason}")]
|
||||
ScreenshotUnavailable { reason: String },
|
||||
}
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
//! Headless hardware [`RenderingContext`] for Servo, vendored from
|
||||
//! `servo-paint-api`'s private `SurfmanRenderingContext` and reshaped
|
||||
//! so it can be constructed without a `RawWindowHandle`.
|
||||
//!
|
||||
//! Why this file exists: `servo-paint-api 0.1` exposes three
|
||||
//! constructors — `SoftwareRenderingContext` (CPU-only),
|
||||
//! `WindowRenderingContext` (requires `DisplayHandle + WindowHandle`),
|
||||
//! and `OffscreenRenderingContext` (must be a child of a
|
||||
//! `WindowRenderingContext`). The sidecar process has no window, so
|
||||
//! none of the three works for us when we want **hardware**
|
||||
//! rasterising. The underlying `SurfmanRenderingContext` glue *can*
|
||||
//! drive a hardware adapter against a `SurfaceType::Generic`
|
||||
//! offscreen surface — that's exactly what we need — but its
|
||||
//! constructor is `fn new` (private). Until Servo accepts an upstream
|
||||
//! PR exposing a headless hardware constructor, this file vendors the
|
||||
//! minimal slice of glue we need.
|
||||
//!
|
||||
//! Scope kept deliberately narrow:
|
||||
//!
|
||||
//! * `prepare_for_rendering`, `read_to_image`, `size`, `resize`,
|
||||
//! `present`, `make_current`, `gleam_gl_api`, `glow_gl_api`, and
|
||||
//! `connection` are vendored. `connection` is mandatory:
|
||||
//! `servo-paint`'s painter calls `rendering_context.connection()
|
||||
//! .expect("Failed to get connection")` while constructing its
|
||||
//! painter, so a `None` default panics the compositor before the
|
||||
//! first frame is ever painted.
|
||||
//! * `create_texture`/`destroy_texture` still fall through to the
|
||||
//! trait defaults — Servo only reaches for them when sharing
|
||||
//! surfman surfaces with its compositor for WebGL/WebGPU, which
|
||||
//! this readback path does not exercise.
|
||||
//! * No `RefreshDriver`. The sidecar drives its own polling loop.
|
||||
//! * The reading path inlines `read_framebuffer_to_image` from the
|
||||
//! same upstream file so we don't take a dependency on a private
|
||||
//! helper that may change shape.
|
||||
//!
|
||||
//! This is feature-gated on `hardware-render`. The default build path
|
||||
//! (and every existing test in this repo) keeps using
|
||||
//! `SoftwareRenderingContext`; the hardware constructor only exists
|
||||
//! when the feature is enabled, which is also when the additional
|
||||
//! surfman/gleam/glow deps are pulled in.
|
||||
|
||||
#![cfg(feature = "hardware-render")]
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dpi::PhysicalSize;
|
||||
use euclid::Size2D;
|
||||
use gleam::gl::{self, Gl};
|
||||
use image::RgbaImage;
|
||||
use servo::{DeviceIntRect, RenderingContext};
|
||||
use surfman::chains::{PreserveBuffer, SwapChain, SwapChainAPI};
|
||||
#[cfg(target_os = "macos")]
|
||||
use surfman::platform::macos::cgl::surface::NativeSurface;
|
||||
use surfman::{
|
||||
Connection, Context, ContextAttributeFlags, ContextAttributes, Device, Error as SurfmanError,
|
||||
GLApi, NativeWidget, Surface, SurfaceAccess, SurfaceType,
|
||||
};
|
||||
|
||||
/// A headless hardware-backed [`RenderingContext`].
|
||||
///
|
||||
/// Construct with [`HardwareOffscreenContext::new`]; drop normally to
|
||||
/// release the surfman context, surface, and swap chain.
|
||||
pub struct HardwareOffscreenContext {
|
||||
size: Cell<PhysicalSize<u32>>,
|
||||
inner: SurfmanInner,
|
||||
swap_chain: SwapChain<Device>,
|
||||
#[cfg(target_os = "macos")]
|
||||
held_presented_surface: RefCell<Option<Surface>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
last_presented_iosurface: RefCell<Option<PresentedIOSurface>>,
|
||||
}
|
||||
|
||||
impl HardwareOffscreenContext {
|
||||
/// Build a new hardware context with an offscreen
|
||||
/// [`SurfaceType::Generic`] surface of the requested size.
|
||||
///
|
||||
/// Uses `Connection::new()` to pick the platform default
|
||||
/// (CGL on macOS — which backs surfaces with `IOSurface`s —
|
||||
/// EGL on Linux, WGL on Windows) and `create_adapter()` for the
|
||||
/// real GPU adapter. Falls back nowhere: if the host can't give
|
||||
/// us a hardware GL context, the returned `Err` carries the
|
||||
/// surfman cause and the caller is expected to either retry with
|
||||
/// the software path or surface the failure.
|
||||
pub fn new(size: PhysicalSize<u32>) -> Result<Self, SurfmanError> {
|
||||
let connection = Connection::new()?;
|
||||
let adapter = connection.create_adapter()?;
|
||||
let inner = SurfmanInner::new(&connection, &adapter)?;
|
||||
let surfman_size = Size2D::new(size.width as i32, size.height as i32);
|
||||
let surface = inner.create_surface(SurfaceType::Generic { size: surfman_size })?;
|
||||
inner.bind_surface(surface)?;
|
||||
inner.make_current()?;
|
||||
let swap_chain = inner.create_attached_swap_chain()?;
|
||||
Ok(Self {
|
||||
size: Cell::new(size),
|
||||
inner,
|
||||
swap_chain,
|
||||
#[cfg(target_os = "macos")]
|
||||
held_presented_surface: RefCell::new(None),
|
||||
#[cfg(target_os = "macos")]
|
||||
last_presented_iosurface: RefCell::new(None),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HardwareOffscreenContext {
|
||||
fn drop(&mut self) {
|
||||
let device = &mut self.inner.device.borrow_mut();
|
||||
let context = &mut self.inner.context.borrow_mut();
|
||||
#[cfg(target_os = "macos")]
|
||||
self.destroy_held_presented_surface(device, context);
|
||||
let _ = self.swap_chain.destroy(device, context);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderingContext for HardwareOffscreenContext {
|
||||
fn prepare_for_rendering(&self) {
|
||||
self.inner.prepare_for_rendering();
|
||||
}
|
||||
|
||||
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
|
||||
self.inner.read_to_image(source_rectangle)
|
||||
}
|
||||
|
||||
fn size(&self) -> PhysicalSize<u32> {
|
||||
self.size.get()
|
||||
}
|
||||
|
||||
fn resize(&self, size: PhysicalSize<u32>) {
|
||||
if self.size.get() == size {
|
||||
return;
|
||||
}
|
||||
|
||||
self.size.set(size);
|
||||
|
||||
let device = &mut self.inner.device.borrow_mut();
|
||||
let context = &mut self.inner.context.borrow_mut();
|
||||
#[cfg(target_os = "macos")]
|
||||
self.destroy_held_presented_surface(device, context);
|
||||
let size = Size2D::new(size.width as i32, size.height as i32);
|
||||
let _ = self.swap_chain.resize(device, context, size);
|
||||
}
|
||||
|
||||
fn present(&self) {
|
||||
let device = &mut self.inner.device.borrow_mut();
|
||||
let context = &mut self.inner.context.borrow_mut();
|
||||
#[cfg(target_os = "macos")]
|
||||
self.recycle_held_presented_surface();
|
||||
let _ = self.swap_chain.swap_buffers(device, context, PreserveBuffer::No);
|
||||
#[cfg(target_os = "macos")]
|
||||
self.capture_presented_iosurface(device);
|
||||
}
|
||||
|
||||
fn make_current(&self) -> Result<(), SurfmanError> {
|
||||
self.inner.make_current()
|
||||
}
|
||||
|
||||
fn gleam_gl_api(&self) -> Rc<dyn Gl> {
|
||||
self.inner.gleam_gl.clone()
|
||||
}
|
||||
|
||||
fn glow_gl_api(&self) -> Arc<glow::Context> {
|
||||
self.inner.glow_gl.clone()
|
||||
}
|
||||
|
||||
fn connection(&self) -> Option<Connection> {
|
||||
Some(self.inner.device.borrow().connection())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl HardwareOffscreenContext {
|
||||
/// Cheap, non-mutating identity probe of the IOSurface that was
|
||||
/// just presented. Used by the sidecar to dedup mach port creation.
|
||||
pub fn peek_iosurface_identity(&self) -> Result<Option<IOSurfaceIdentity>, SurfmanError> {
|
||||
Ok(self.last_presented_iosurface.borrow().as_ref().map(|surface| surface.identity))
|
||||
}
|
||||
|
||||
/// Snapshot the just-presented IOSurface and return its mach port
|
||||
/// name plus dimensions and stable surface id. Increments the
|
||||
/// IOSurface's mach-port use count; the
|
||||
/// receiving process holds it via `IOSurfaceLookupFromMachPort` and
|
||||
/// is responsible for `mach_port_deallocate` once the import is
|
||||
/// finished.
|
||||
pub fn current_iosurface_mach_port(&self) -> Result<IOSurfaceHandle, SurfmanError> {
|
||||
let presented = self.last_presented_iosurface.borrow();
|
||||
let presented = presented.as_ref().ok_or(SurfmanError::Failed)?;
|
||||
let mach_port = presented.native.0.create_mach_port();
|
||||
Ok(IOSurfaceHandle {
|
||||
mach_port_name: mach_port,
|
||||
surface_id: presented.identity.surface_id,
|
||||
width: presented.identity.width,
|
||||
height: presented.identity.height,
|
||||
})
|
||||
}
|
||||
|
||||
fn capture_presented_iosurface(&self, device: &mut Device) {
|
||||
let Some(surface) = self.swap_chain.take_pending_surface() else {
|
||||
self.last_presented_iosurface.borrow_mut().take();
|
||||
return;
|
||||
};
|
||||
let info = device.surface_info(&surface);
|
||||
let native = device.native_surface(&surface);
|
||||
let identity = IOSurfaceIdentity {
|
||||
surface_id: info.id.0 as u64,
|
||||
width: u32::try_from(info.size.width).unwrap_or(0),
|
||||
height: u32::try_from(info.size.height).unwrap_or(0),
|
||||
};
|
||||
self.held_presented_surface.replace(Some(surface));
|
||||
self.last_presented_iosurface.replace(Some(PresentedIOSurface { identity, native }));
|
||||
}
|
||||
|
||||
fn recycle_held_presented_surface(&self) {
|
||||
if let Some(surface) = self.held_presented_surface.borrow_mut().take() {
|
||||
self.swap_chain.recycle_surface(surface);
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy_held_presented_surface(&self, device: &mut Device, context: &mut Context) {
|
||||
self.last_presented_iosurface.borrow_mut().take();
|
||||
if let Some(mut surface) = self.held_presented_surface.borrow_mut().take() {
|
||||
let _ = device.destroy_surface(context, &mut surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
struct PresentedIOSurface {
|
||||
identity: IOSurfaceIdentity,
|
||||
native: NativeSurface,
|
||||
}
|
||||
|
||||
/// Trimmed mirror of `paint_api::rendering_context::SurfmanRenderingContext`.
|
||||
///
|
||||
/// Only the methods the public type above actually uses are kept; the
|
||||
/// upstream original also wires up texture sharing, refresh drivers,
|
||||
/// and several other knobs that Servo's compositor reaches into but
|
||||
/// the embedder's headless readback path does not.
|
||||
struct SurfmanInner {
|
||||
gleam_gl: Rc<dyn Gl>,
|
||||
glow_gl: Arc<glow::Context>,
|
||||
device: RefCell<Device>,
|
||||
context: RefCell<Context>,
|
||||
}
|
||||
|
||||
impl Drop for SurfmanInner {
|
||||
fn drop(&mut self) {
|
||||
let device = &mut self.device.borrow_mut();
|
||||
let context = &mut self.context.borrow_mut();
|
||||
let _ = device.destroy_context(context);
|
||||
}
|
||||
}
|
||||
|
||||
impl SurfmanInner {
|
||||
fn new(connection: &Connection, adapter: &surfman::Adapter) -> Result<Self, SurfmanError> {
|
||||
let device = connection.create_device(adapter)?;
|
||||
|
||||
let flags = ContextAttributeFlags::ALPHA
|
||||
| ContextAttributeFlags::DEPTH
|
||||
| ContextAttributeFlags::STENCIL;
|
||||
let gl_api = connection.gl_api();
|
||||
let version = match &gl_api {
|
||||
GLApi::GLES => surfman::GLVersion { major: 3, minor: 0 },
|
||||
GLApi::GL => surfman::GLVersion { major: 3, minor: 2 },
|
||||
};
|
||||
let context_descriptor =
|
||||
device.create_context_descriptor(&ContextAttributes { flags, version })?;
|
||||
let context = device.create_context(&context_descriptor, None)?;
|
||||
|
||||
// Loading the GL function pointers requires unsafe ABI calls
|
||||
// through surfman's `get_proc_address` — these are the same
|
||||
// calls the upstream `SurfmanRenderingContext::new` makes,
|
||||
// and they're sound for the same reason: surfman guarantees
|
||||
// the returned function pointers match the requested API.
|
||||
#[expect(unsafe_code)]
|
||||
let gleam_gl = {
|
||||
match gl_api {
|
||||
GLApi::GL => unsafe {
|
||||
gl::GlFns::load_with(|name| device.get_proc_address(&context, name))
|
||||
},
|
||||
GLApi::GLES => unsafe {
|
||||
gl::GlesFns::load_with(|name| device.get_proc_address(&context, name))
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
let glow_gl = unsafe {
|
||||
glow::Context::from_loader_function(|name| device.get_proc_address(&context, name))
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
gleam_gl,
|
||||
glow_gl: Arc::new(glow_gl),
|
||||
device: RefCell::new(device),
|
||||
context: RefCell::new(context),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_surface(
|
||||
&self,
|
||||
surface_type: SurfaceType<NativeWidget>,
|
||||
) -> Result<Surface, SurfmanError> {
|
||||
let device = &mut self.device.borrow_mut();
|
||||
let context = &self.context.borrow();
|
||||
device.create_surface(context, SurfaceAccess::GPUOnly, surface_type)
|
||||
}
|
||||
|
||||
fn bind_surface(&self, surface: Surface) -> Result<(), SurfmanError> {
|
||||
let device = &self.device.borrow();
|
||||
let context = &mut self.context.borrow_mut();
|
||||
device.bind_surface_to_context(context, surface).map_err(|(err, mut surface)| {
|
||||
let _ = device.destroy_surface(context, &mut surface);
|
||||
err
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_attached_swap_chain(&self) -> Result<SwapChain<Device>, SurfmanError> {
|
||||
let device = &mut self.device.borrow_mut();
|
||||
let context = &mut self.context.borrow_mut();
|
||||
SwapChain::create_attached(device, context, SurfaceAccess::GPUOnly)
|
||||
}
|
||||
|
||||
fn make_current(&self) -> Result<(), SurfmanError> {
|
||||
let device = &self.device.borrow();
|
||||
let context = &self.context.borrow();
|
||||
device.make_context_current(context)
|
||||
}
|
||||
|
||||
fn framebuffer_id(&self) -> u32 {
|
||||
let device = &self.device.borrow();
|
||||
let context = &self.context.borrow();
|
||||
device
|
||||
.context_surface_info(context)
|
||||
.unwrap_or(None)
|
||||
.and_then(|info| info.framebuffer_object)
|
||||
.map_or(0, |framebuffer| framebuffer.0.into())
|
||||
}
|
||||
|
||||
fn prepare_for_rendering(&self) {
|
||||
let framebuffer_id = self.framebuffer_id();
|
||||
self.gleam_gl.bind_framebuffer(gleam::gl::FRAMEBUFFER, framebuffer_id);
|
||||
}
|
||||
|
||||
/// Inlined copy of `Framebuffer::read_framebuffer_to_image` from
|
||||
/// `paint-api`. Reads the bound framebuffer into a `Vec<u8>`,
|
||||
/// flips it vertically (GL's origin is bottom-left, the rest of
|
||||
/// the embedder expects top-left), and returns it as an
|
||||
/// [`RgbaImage`]. Returns `None` if `RgbaImage::from_raw` rejects
|
||||
/// the buffer (size mismatch); GL errors are logged but don't
|
||||
/// abort the read — the caller can decide whether a corrupt
|
||||
/// frame is recoverable.
|
||||
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
|
||||
let framebuffer_id = self.framebuffer_id();
|
||||
self.gleam_gl.bind_framebuffer(gl::FRAMEBUFFER, framebuffer_id);
|
||||
// Working around an OSMesa headless bug carried forward from
|
||||
// the upstream implementation, see servo/servo#18606.
|
||||
self.gleam_gl.bind_vertex_array(0);
|
||||
|
||||
let mut pixels = self.gleam_gl.read_pixels(
|
||||
source_rectangle.min.x,
|
||||
source_rectangle.min.y,
|
||||
source_rectangle.width(),
|
||||
source_rectangle.height(),
|
||||
gl::RGBA,
|
||||
gl::UNSIGNED_BYTE,
|
||||
);
|
||||
let gl_error = self.gleam_gl.get_error();
|
||||
if gl_error != gl::NO_ERROR {
|
||||
log::warn!("GL error 0x{gl_error:x} after read_pixels in hardware offscreen context");
|
||||
}
|
||||
|
||||
let source_rectangle = source_rectangle.to_usize();
|
||||
let stride = source_rectangle.width().checked_mul(4)?;
|
||||
let mirror = pixels.clone();
|
||||
for y in 0..source_rectangle.height() {
|
||||
let dst_start = y.checked_mul(stride)?;
|
||||
let src_start = (source_rectangle.height().checked_sub(y + 1)?).checked_mul(stride)?;
|
||||
let dst_end = dst_start.checked_add(stride)?;
|
||||
let src_end = src_start.checked_add(stride)?;
|
||||
if dst_end > pixels.len() || src_end > mirror.len() {
|
||||
return None;
|
||||
}
|
||||
pixels[dst_start..dst_end].clone_from_slice(&mirror[src_start..src_end]);
|
||||
}
|
||||
|
||||
RgbaImage::from_raw(
|
||||
source_rectangle.width() as u32,
|
||||
source_rectangle.height() as u32,
|
||||
pixels,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -284,11 +284,6 @@ pub struct KeyboardTextRequest {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ScreenshotRequest {
|
||||
pub webview_id: WebViewId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PermissionRequest {
|
||||
pub webview_id: WebViewId,
|
||||
@@ -341,11 +336,6 @@ pub trait ServoHost {
|
||||
|
||||
fn type_text(&mut self, request: KeyboardTextRequest) -> Result<(), ServoHostError>;
|
||||
|
||||
fn capture_screenshot(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
) -> Result<RenderedFrame, ServoHostError>;
|
||||
|
||||
fn set_permission(
|
||||
&mut self,
|
||||
request: PermissionRequest,
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
//! Cross-process IOSurface descriptor types.
|
||||
//!
|
||||
//! These wire types live outside `hardware_rendering_context` (which
|
||||
//! is hardware-render + macOS gated) so the sidecar's JSON protocol
|
||||
//! can carry an `Option<IOSurfaceHandle>` regardless of feature
|
||||
//! flags. The receiver always knows how to parse the field; if no
|
||||
//! sender ever populates it (software-only build), it's just `None`
|
||||
//! on every frame.
|
||||
//!
|
||||
//! Minting an [`IOSurfaceHandle`] requires a hardware surfman context
|
||||
//! and a macOS host. That part lives in
|
||||
//! [`crate::hardware_rendering_context`].
|
||||
|
||||
/// Cross-process handle to a hardware surface: the receiving process
|
||||
/// rebuilds an `IOSurfaceRef` from `mach_port_name` and imports it as
|
||||
/// a Metal texture without copying pixels.
|
||||
///
|
||||
/// `surface_id` is the stable surfman `SurfaceID` (a pointer-shaped
|
||||
/// `usize` widened to `u64` for the wire). Together with `width` and
|
||||
/// `height` it lets the receiver dedup imported IOSurfaces. The pixel
|
||||
/// dimensions are part of the identity because a resize can reuse the
|
||||
/// same surfman id for a newly-sized IOSurface. `width` and `height`
|
||||
/// are reported in surface pixels (post-DPR).
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[cfg_attr(feature = "servo-engine", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct IOSurfaceHandle {
|
||||
pub mach_port_name: u32,
|
||||
pub surface_id: u64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Identity-only peek of the currently bound IOSurface. Distinguishes
|
||||
/// "same surface as last frame" from "resize/swap rotated to a new
|
||||
/// surface" without minting a fresh mach port (mach ports are a scarce
|
||||
/// kernel resource and `IOSurfaceCreateMachPort` is not cheap).
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct IOSurfaceIdentity {
|
||||
pub surface_id: u64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl IOSurfaceIdentity {
|
||||
pub fn from_handle(handle: IOSurfaceHandle) -> Self {
|
||||
Self { surface_id: handle.surface_id, width: handle.width, height: handle.height }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
mod error;
|
||||
#[cfg(feature = "hardware-render")]
|
||||
mod hardware_rendering_context;
|
||||
mod host;
|
||||
mod iosurface_handle;
|
||||
#[cfg(feature = "servo-engine")]
|
||||
mod keyboard;
|
||||
#[cfg(feature = "servo-engine")]
|
||||
@@ -17,14 +14,11 @@ mod runtime_waker;
|
||||
mod runtime_webview;
|
||||
|
||||
pub use error::ServoHostError;
|
||||
#[cfg(feature = "hardware-render")]
|
||||
pub use hardware_rendering_context::HardwareOffscreenContext;
|
||||
pub use host::{
|
||||
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest,
|
||||
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame,
|
||||
RenderedFrameSummary, ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost,
|
||||
TouchTapRequest, WebViewSnapshot, WebViewState,
|
||||
RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest,
|
||||
WebViewSnapshot, WebViewState,
|
||||
};
|
||||
pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity};
|
||||
#[cfg(feature = "servo-engine")]
|
||||
pub use runtime::{RenderingContextKind, ServoSurfaceSize, SoftwareServoHost};
|
||||
|
||||
@@ -4,18 +4,17 @@ use std::{
|
||||
path::PathBuf,
|
||||
rc::Rc,
|
||||
sync::{
|
||||
Arc,
|
||||
Arc, Once,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use dpi::PhysicalSize;
|
||||
use ely_domain::{ProfileId, TabId, WebViewId};
|
||||
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||
use servo::{
|
||||
DevicePoint, DeviceVector2D, Opts, Scroll, Servo, ServoBuilder, WebViewBuilder, WebViewPoint,
|
||||
WebViewVector,
|
||||
DevicePoint, DeviceVector2D, Opts, Preferences, Scroll, Servo, ServoBuilder, WebViewBuilder,
|
||||
WebViewPoint, WebViewVector,
|
||||
};
|
||||
|
||||
#[path = "runtime_context.rs"]
|
||||
@@ -28,8 +27,8 @@ use url::Url;
|
||||
use crate::{
|
||||
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest,
|
||||
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame,
|
||||
ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost, ServoHostError, TouchTapRequest,
|
||||
WebViewSnapshot, WebViewState,
|
||||
ResizeRequest, ScrollRequest, ServoHost, ServoHostError, TouchTapRequest, WebViewSnapshot,
|
||||
WebViewState,
|
||||
runtime_input::{
|
||||
send_keyboard_text, send_mouse_click, send_mouse_drag, send_mouse_hover, send_touch_tap,
|
||||
},
|
||||
@@ -39,8 +38,7 @@ use crate::{
|
||||
};
|
||||
|
||||
static SERVO_RUNTIME_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const SCREENSHOT_POLL_INTERVAL: Duration = Duration::from_millis(2);
|
||||
static RUSTLS_PROVIDER: Once = Once::new();
|
||||
|
||||
pub struct SoftwareServoHost {
|
||||
servo: Servo,
|
||||
@@ -70,12 +68,6 @@ impl SoftwareServoHost {
|
||||
config_dir: Option<PathBuf>,
|
||||
rendering_context_kind: RenderingContextKind,
|
||||
) -> Result<Self, ServoHostError> {
|
||||
if rendering_context_kind == RenderingContextKind::Hardware
|
||||
&& !cfg!(feature = "hardware-render")
|
||||
{
|
||||
return Err(ServoHostError::HardwareRenderUnavailable);
|
||||
}
|
||||
|
||||
if SERVO_RUNTIME_STARTED
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
@@ -96,7 +88,22 @@ impl SoftwareServoHost {
|
||||
profile_id: ProfileId,
|
||||
size: ServoSurfaceSize,
|
||||
) -> Result<WebViewId, ServoHostError> {
|
||||
self.create_webview_in_context(tab_id, profile_id, size)
|
||||
let handles = self.new_rendering_context(size)?;
|
||||
self.create_webview_in_context(tab_id, profile_id, handles)
|
||||
}
|
||||
|
||||
pub fn create_webview_with_native_surface<S>(
|
||||
&mut self,
|
||||
tab_id: TabId,
|
||||
profile_id: ProfileId,
|
||||
size: ServoSurfaceSize,
|
||||
native_surface: &S,
|
||||
) -> Result<WebViewId, ServoHostError>
|
||||
where
|
||||
S: HasDisplayHandle + HasWindowHandle + ?Sized,
|
||||
{
|
||||
let handles = self.new_rendering_context_for_native_surface(size, native_surface)?;
|
||||
self.create_webview_in_context(tab_id, profile_id, handles)
|
||||
}
|
||||
|
||||
/// Paint and present the current surface without RGBA readback.
|
||||
@@ -121,8 +128,10 @@ impl SoftwareServoHost {
|
||||
config_dir: Option<PathBuf>,
|
||||
rendering_context_kind: RenderingContextKind,
|
||||
) -> Result<Self, ServoHostError> {
|
||||
install_rustls_provider();
|
||||
let wake_requested = Arc::new(AtomicBool::new(false));
|
||||
let mut builder = ServoBuilder::default()
|
||||
.preferences(ely_servo_preferences())
|
||||
.event_loop_waker(Box::new(ServoWakeFlag::new(wake_requested.clone())));
|
||||
if let Some(config_dir) = config_dir {
|
||||
builder = builder.opts(Opts { config_dir: Some(config_dir), ..Opts::default() });
|
||||
@@ -183,13 +192,24 @@ impl SoftwareServoHost {
|
||||
}
|
||||
}
|
||||
|
||||
fn install_rustls_provider() {
|
||||
RUSTLS_PROVIDER.call_once(|| {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
});
|
||||
}
|
||||
|
||||
fn ely_servo_preferences() -> Preferences {
|
||||
Preferences { dom_intersection_observer_enabled: true, ..Preferences::default() }
|
||||
}
|
||||
|
||||
impl ServoHost for SoftwareServoHost {
|
||||
fn create_webview(
|
||||
&mut self,
|
||||
tab_id: TabId,
|
||||
profile_id: ProfileId,
|
||||
) -> Result<WebViewId, ServoHostError> {
|
||||
self.create_webview_in_context(tab_id, profile_id, self.default_surface_size)
|
||||
let handles = self.new_rendering_context(self.default_surface_size)?;
|
||||
self.create_webview_in_context(tab_id, profile_id, handles)
|
||||
}
|
||||
|
||||
fn navigate(&mut self, request: NavigationRequest) -> Result<(), ServoHostError> {
|
||||
@@ -313,41 +333,6 @@ impl ServoHost for SoftwareServoHost {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capture_screenshot(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
) -> Result<RenderedFrame, ServoHostError> {
|
||||
let webview = self.webview(&request.webview_id)?.webview.clone();
|
||||
let captured_image = Rc::new(RefCell::new(None));
|
||||
let callback_image = captured_image.clone();
|
||||
webview.take_screenshot(None, move |result| {
|
||||
callback_image.replace(Some(result));
|
||||
});
|
||||
|
||||
let started_at = Instant::now();
|
||||
while captured_image.borrow().is_none() {
|
||||
if started_at.elapsed() >= SCREENSHOT_TIMEOUT {
|
||||
return Err(ServoHostError::ScreenshotTimedOut { id: request.webview_id.clone() });
|
||||
}
|
||||
|
||||
self.tick();
|
||||
if self.snapshot(&request.webview_id)?.has_pending_frame() {
|
||||
self.paint(&request.webview_id)?;
|
||||
}
|
||||
thread::sleep(SCREENSHOT_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
let Some(result) = captured_image.borrow_mut().take() else {
|
||||
return Err(ServoHostError::RenderedFrameUnavailable);
|
||||
};
|
||||
let image = result.map_err(|error| ServoHostError::ScreenshotUnavailable {
|
||||
reason: format!("{error:?}"),
|
||||
})?;
|
||||
let frame = RenderedFrame::from_rgba_bytes(image.width(), image.height(), image.into_raw());
|
||||
self.last_rendered_frame = Some(frame.clone());
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
fn set_permission(
|
||||
&mut self,
|
||||
request: PermissionRequest,
|
||||
@@ -392,15 +377,20 @@ impl ServoHost for SoftwareServoHost {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SoftwareServoHost {
|
||||
fn drop(&mut self) {
|
||||
SERVO_RUNTIME_STARTED.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
impl SoftwareServoHost {
|
||||
fn create_webview_in_context(
|
||||
&mut self,
|
||||
tab_id: TabId,
|
||||
profile_id: ProfileId,
|
||||
size: ServoSurfaceSize,
|
||||
handles: runtime_context::RenderingContextHandles,
|
||||
) -> Result<WebViewId, ServoHostError> {
|
||||
let webview_id = WebViewId::new();
|
||||
let handles = self.new_rendering_context(size)?;
|
||||
let delegate =
|
||||
Rc::new(HostWebViewDelegate::new(profile_id.clone(), self.permissions.clone()));
|
||||
let webview = WebViewBuilder::new(&self.servo, handles.rendering_context.clone())
|
||||
@@ -420,8 +410,6 @@ impl SoftwareServoHost {
|
||||
tab_id,
|
||||
profile_id,
|
||||
rendering_context: handles.rendering_context,
|
||||
#[cfg(feature = "hardware-render")]
|
||||
hardware_context: handles.hardware_context,
|
||||
webview,
|
||||
delegate,
|
||||
requested_url: None,
|
||||
@@ -431,42 +419,6 @@ impl SoftwareServoHost {
|
||||
Ok(webview_id)
|
||||
}
|
||||
|
||||
/// Cheap peek at the IOSurface identity bound to this webview's
|
||||
/// hardware context. Returns `None` for software webviews and on
|
||||
/// non-macOS hosts; otherwise the surfman `SurfaceID`-derived
|
||||
/// identity plus dimensions. Used by the sidecar's live loop to
|
||||
/// dedup mach port creation.
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
pub fn peek_iosurface_identity(
|
||||
&self,
|
||||
webview_id: &WebViewId,
|
||||
) -> Result<Option<crate::IOSurfaceIdentity>, ServoHostError> {
|
||||
let webview = self.webview(webview_id)?;
|
||||
let Some(hardware) = webview.hardware_context.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
hardware.peek_iosurface_identity().map_err(|_| ServoHostError::RenderingContextUnavailable)
|
||||
}
|
||||
|
||||
/// Mint a fresh mach port for the IOSurface bound to this
|
||||
/// webview's hardware context. The caller is responsible for
|
||||
/// transferring the port to the receiving process; if no transfer
|
||||
/// happens, the port leaks. Software webviews return `None`.
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
pub fn current_iosurface_handle(
|
||||
&self,
|
||||
webview_id: &WebViewId,
|
||||
) -> Result<Option<crate::IOSurfaceHandle>, ServoHostError> {
|
||||
let webview = self.webview(webview_id)?;
|
||||
let Some(hardware) = webview.hardware_context.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
hardware
|
||||
.current_iosurface_mach_port()
|
||||
.map(Some)
|
||||
.map_err(|_| ServoHostError::RenderingContextUnavailable)
|
||||
}
|
||||
|
||||
fn webview(&self, webview_id: &WebViewId) -> Result<&HostWebView, ServoHostError> {
|
||||
self.webviews
|
||||
.get(webview_id)
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::{
|
||||
|
||||
use dpi::PhysicalSize;
|
||||
use euclid::Scale;
|
||||
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||
use servo::{
|
||||
DeviceIndependentPixel, DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePixel,
|
||||
RenderingContext,
|
||||
@@ -62,31 +63,16 @@ impl ServoSurfaceSize {
|
||||
}
|
||||
|
||||
/// Selects the `RenderingContext` implementation each webview gets.
|
||||
///
|
||||
/// `Software` uses Servo's built-in `SoftwareRenderingContext`, which
|
||||
/// rasterises on the CPU. `Hardware` uses the vendored
|
||||
/// [`HardwareOffscreenContext`](crate::HardwareOffscreenContext),
|
||||
/// which rasterises through the real GPU adapter against a
|
||||
/// `SurfaceType::Generic` offscreen surface. The `Hardware` variant
|
||||
/// is only available when the `hardware-render` feature is enabled;
|
||||
/// requesting it without the feature is a configuration error
|
||||
/// surfaced via `ServoHostError::HardwareRenderUnavailable`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum RenderingContextKind {
|
||||
#[default]
|
||||
Software,
|
||||
Hardware,
|
||||
}
|
||||
|
||||
/// Pair of rendering-context handles produced by
|
||||
/// [`SoftwareServoHost::new_rendering_context`]. The trait-object
|
||||
/// handle drives Servo's compositor; the concrete hardware handle is
|
||||
/// kept on the side so the host can call macOS-specific methods
|
||||
/// (IOSurface mach port extraction) without downcasting.
|
||||
/// [`SoftwareServoHost::new_rendering_context`].
|
||||
pub(super) struct RenderingContextHandles {
|
||||
pub(super) rendering_context: Rc<dyn RenderingContext>,
|
||||
#[cfg(feature = "hardware-render")]
|
||||
pub(super) hardware_context: Option<Rc<crate::HardwareOffscreenContext>>,
|
||||
}
|
||||
|
||||
impl SoftwareServoHost {
|
||||
@@ -103,29 +89,33 @@ impl SoftwareServoHost {
|
||||
rendering_context
|
||||
.make_current()
|
||||
.map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
|
||||
Ok(RenderingContextHandles {
|
||||
rendering_context,
|
||||
#[cfg(feature = "hardware-render")]
|
||||
hardware_context: None,
|
||||
})
|
||||
Ok(RenderingContextHandles { rendering_context })
|
||||
}
|
||||
#[cfg(feature = "hardware-render")]
|
||||
RenderingContextKind::Hardware => {
|
||||
let hardware = Rc::new(
|
||||
crate::HardwareOffscreenContext::new(size.physical())
|
||||
.map_err(|_| ServoHostError::RenderingContextUnavailable)?,
|
||||
);
|
||||
hardware.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
|
||||
Ok(RenderingContextHandles {
|
||||
rendering_context: hardware.clone(),
|
||||
hardware_context: Some(hardware),
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "hardware-render"))]
|
||||
RenderingContextKind::Hardware => Err(ServoHostError::HardwareRenderUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn new_rendering_context_for_native_surface<S>(
|
||||
&self,
|
||||
size: ServoSurfaceSize,
|
||||
native_surface: &S,
|
||||
) -> Result<RenderingContextHandles, ServoHostError>
|
||||
where
|
||||
S: HasDisplayHandle + HasWindowHandle + ?Sized,
|
||||
{
|
||||
let display_handle = native_surface
|
||||
.display_handle()
|
||||
.map_err(|_| ServoHostError::RenderingContextUnavailable)?;
|
||||
let window_handle = native_surface
|
||||
.window_handle()
|
||||
.map_err(|_| ServoHostError::RenderingContextUnavailable)?;
|
||||
let rendering_context = Rc::new(
|
||||
servo::WindowRenderingContext::new(display_handle, window_handle, size.physical())
|
||||
.map_err(|_| ServoHostError::RenderingContextUnavailable)?,
|
||||
);
|
||||
rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
|
||||
Ok(RenderingContextHandles { rendering_context })
|
||||
}
|
||||
|
||||
/// Spin Servo's event loop until the webview's delegate observes a
|
||||
/// fresh `notify_new_frame_ready` callback (i.e. the framebuffer is
|
||||
/// consistent for readback) or [`paint_barrier_budget`] elapses. The
|
||||
|
||||
@@ -13,12 +13,6 @@ pub(super) struct HostWebView {
|
||||
pub(super) tab_id: TabId,
|
||||
pub(super) profile_id: ProfileId,
|
||||
pub(super) rendering_context: Rc<dyn RenderingContext>,
|
||||
/// Parallel concrete handle when the rendering context is the
|
||||
/// vendored hardware path. `None` for software webviews. Lets the
|
||||
/// host call macOS-specific methods (IOSurface mach port
|
||||
/// extraction) without downcasting `dyn RenderingContext`.
|
||||
#[cfg(feature = "hardware-render")]
|
||||
pub(super) hardware_context: Option<Rc<crate::HardwareOffscreenContext>>,
|
||||
pub(super) webview: WebView,
|
||||
pub(super) delegate: Rc<HostWebViewDelegate>,
|
||||
pub(super) requested_url: Option<String>,
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
//! Smoke test for the vendored hardware [`RenderingContext`].
|
||||
//!
|
||||
//! Runs only when the `hardware-render` feature is enabled. The test
|
||||
//! degrades gracefully when the host machine lacks a hardware GL
|
||||
//! adapter (CI sandboxes, no-GPU containers): construction returns
|
||||
//! `Err`, the test logs the cause, and reports `ok` — proving the
|
||||
//! vendored constructor is wired up correctly without falsely
|
||||
//! marking the suite green when a GPU is actually expected and
|
||||
//! missing. Inverting that check (turning a GPU-missing host into a
|
||||
//! hard failure) is left for downstream CI configuration once the
|
||||
//! hardware path is wired into the sidecar binary.
|
||||
|
||||
#![cfg(feature = "hardware-render")]
|
||||
|
||||
use dpi::PhysicalSize;
|
||||
use ely_servo_host::HardwareOffscreenContext;
|
||||
use servo::RenderingContext;
|
||||
|
||||
#[test]
|
||||
fn constructs_or_explains_why_not() {
|
||||
let size = PhysicalSize::new(640, 480);
|
||||
match HardwareOffscreenContext::new(size) {
|
||||
Ok(context) => {
|
||||
// We don't drive Servo here — just confirm the vendored
|
||||
// glue produced a live context. Construction is the
|
||||
// failure mode this smoke test guards against; once a
|
||||
// context exists the real Servo paint path exercises the
|
||||
// rest of the surface.
|
||||
drop(context);
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"hardware GL adapter not available on this host \
|
||||
(acceptable in headless / no-GPU environments): {error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn extracts_iosurface_mach_port_from_current_surface() -> Result<(), String> {
|
||||
let width = 256;
|
||||
let height = 192;
|
||||
let context = match HardwareOffscreenContext::new(PhysicalSize::new(width, height)) {
|
||||
Ok(context) => context,
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"hardware GL adapter not available on this host \
|
||||
(acceptable in headless / no-GPU environments): {error:?}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
context.prepare_for_rendering();
|
||||
context.present();
|
||||
let first = context
|
||||
.current_iosurface_mach_port()
|
||||
.map_err(|error| format!("first IOSurface mach port extraction failed: {error:?}"))?;
|
||||
assert!(
|
||||
first.mach_port_name != 0,
|
||||
"IOSurfaceCreateMachPort must return a non-null mach_port_t (got 0)"
|
||||
);
|
||||
assert_eq!(first.width, width, "reported width must match surface width");
|
||||
assert_eq!(first.height, height, "reported height must match surface height");
|
||||
|
||||
// The unbind/rebind cycle must leave the context usable: a second
|
||||
// call should still produce a valid mach port without panicking on
|
||||
// a stale `Framebuffer::None`.
|
||||
let second = context
|
||||
.current_iosurface_mach_port()
|
||||
.map_err(|error| format!("repeated mach port extraction failed: {error:?}"))?;
|
||||
assert!(second.mach_port_name != 0);
|
||||
assert_eq!(second.width, width);
|
||||
assert_eq!(second.height, height);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
//! Manual sidecar perf bench; ignored by normal CI.
|
||||
#![cfg(feature = "servo-engine")]
|
||||
|
||||
#[path = "live_perf_bench/pixels.rs"]
|
||||
mod pixels;
|
||||
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
fs,
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
path::PathBuf,
|
||||
process::{Child, ChildStdin, ChildStdout, Command, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
use serde::Deserialize;
|
||||
|
||||
const DEFAULT_FRAMES: u32 = 240;
|
||||
const VIEWPORT_WIDTH: u32 = 1024;
|
||||
const VIEWPORT_HEIGHT: u32 = 768;
|
||||
const SCROLL_STEP_PX: i32 = 4;
|
||||
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const SCROLL_PAGE_DATA_URL: &str = "data:text/html,\
|
||||
<!doctype html><meta charset=utf-8><title>perf</title>\
|
||||
<style>html,body{margin:0;padding:0}\
|
||||
body{height:8000px;background:linear-gradient(180deg,red,teal,navy,white,crimson)}\
|
||||
div.row{height:80px;border-bottom:2px solid rgba(0,0,0,.5);color:white;font:24px/80px sans-serif;padding-left:24px}\
|
||||
</style>\
|
||||
<script>for(let i=0;i<100;i++){let d=document.createElement('div');d.className='row';d.textContent='row '+i;document.body.appendChild(d)}</script>";
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct LiveResponse {
|
||||
error: Option<String>,
|
||||
frame: Option<LiveFrameReport>,
|
||||
#[serde(default)]
|
||||
perf: Option<FramePerfSummary>,
|
||||
#[serde(default)]
|
||||
surface_handle: Option<BenchSurfaceHandle>,
|
||||
#[serde(default)]
|
||||
current_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy)]
|
||||
struct BenchSurfaceHandle {
|
||||
mach_port_name: u32,
|
||||
surface_id: u64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct LiveFrameReport {
|
||||
rgba_byte_count: usize,
|
||||
#[serde(default)]
|
||||
width: u32,
|
||||
#[serde(default)]
|
||||
height: u32,
|
||||
#[serde(default)]
|
||||
device_pixel_ratio: f32,
|
||||
#[serde(default)]
|
||||
css_viewport_width: u32,
|
||||
#[serde(default)]
|
||||
css_viewport_height: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct FramePerfSummary {
|
||||
window: u32,
|
||||
context: String,
|
||||
paint_p50_us: u64,
|
||||
paint_p95_us: u64,
|
||||
paint_p99_us: u64,
|
||||
encode_p50_us: u64,
|
||||
encode_p95_us: u64,
|
||||
encode_p99_us: u64,
|
||||
write_p50_us: u64,
|
||||
write_p95_us: u64,
|
||||
write_p99_us: u64,
|
||||
total_p50_us: u64,
|
||||
total_p95_us: u64,
|
||||
total_p99_us: u64,
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual bench: spawns sidecar, scrolls a data: URL for N frames"]
|
||||
fn run_live_bench() -> Result<(), Box<dyn Error>> {
|
||||
let kind = env::var("ELY_PERF_KIND").unwrap_or_else(|_| "software".to_string());
|
||||
let frames: u32 = env::var("ELY_PERF_FRAMES")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(DEFAULT_FRAMES);
|
||||
let url = env::var("ELY_PERF_URL").unwrap_or_else(|_| SCROLL_PAGE_DATA_URL.to_string());
|
||||
|
||||
let profile_id = ProfileId::new();
|
||||
let tab = TabId::new();
|
||||
let profile_data_dir = env::temp_dir().join(format!(
|
||||
"ely-perf-bench-{}-{}-{}",
|
||||
std::process::id(),
|
||||
kind,
|
||||
profile_id.as_str()
|
||||
));
|
||||
fs::create_dir_all(&profile_data_dir)?;
|
||||
|
||||
let mut child = spawn_sidecar(&kind, &profile_data_dir)?;
|
||||
let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?;
|
||||
let stdout = child.stdout.take().ok_or("sidecar stdout missing")?;
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
let outcome = match drive_bench(&mut stdin, &mut reader, &kind, &tab, &profile_id, &url, frames)
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => {
|
||||
drop(stdin);
|
||||
let _ = child.kill();
|
||||
cleanup(&profile_data_dir)?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
drop(stdin);
|
||||
let _ = child.wait();
|
||||
cleanup(&profile_data_dir)?;
|
||||
|
||||
print_summaries(&kind, frames, &outcome.summaries);
|
||||
print_surface_handles(&kind, &outcome.surface_handles);
|
||||
print_current_surface_summary(&kind, &outcome.current_surface_ids);
|
||||
eprintln!(
|
||||
"\n=== ELY_PERF_KIND={kind} readback_rgba_bytes={} surface_rgba_bytes={} ===",
|
||||
outcome.readback_rgba_bytes, outcome.surface_rgba_bytes,
|
||||
);
|
||||
assert!(
|
||||
!outcome.summaries.is_empty(),
|
||||
"expected at least one FramePerfSummary across {frames} frames"
|
||||
);
|
||||
if kind == "hardware" {
|
||||
assert!(
|
||||
!outcome.surface_handles.is_empty(),
|
||||
"hardware live path must publish IOSurface handles"
|
||||
);
|
||||
assert!(
|
||||
!outcome.current_surface_ids.is_empty(),
|
||||
"hardware live path must report current_surface_id selectors"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
outcome.surface_handles.is_empty(),
|
||||
"software path must never publish an IOSurface handle"
|
||||
);
|
||||
assert!(
|
||||
outcome.current_surface_ids.is_empty(),
|
||||
"software path must never report current_surface_id"
|
||||
);
|
||||
}
|
||||
let viewport_bytes = (1024u64) * (768u64) * 4;
|
||||
let total_rgba_bytes = outcome.readback_rgba_bytes + outcome.surface_rgba_bytes;
|
||||
assert!(
|
||||
total_rgba_bytes >= viewport_bytes,
|
||||
"{kind} path delivered only {total_rgba_bytes} bytes — expected at least one full frame ({})",
|
||||
viewport_bytes,
|
||||
);
|
||||
if kind == "hardware" {
|
||||
let full_readback_budget = viewport_bytes * u64::from(frames);
|
||||
assert_eq!(
|
||||
outcome.readback_rgba_bytes, viewport_bytes,
|
||||
"hardware path should read back only the initial visible frame"
|
||||
);
|
||||
assert!(
|
||||
total_rgba_bytes < full_readback_budget,
|
||||
"hardware path stayed on full readback: {total_rgba_bytes} >= {full_readback_budget}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct BenchOutcome {
|
||||
summaries: Vec<FramePerfSummary>,
|
||||
surface_handles: Vec<BenchSurfaceHandle>,
|
||||
current_surface_ids: Vec<u64>,
|
||||
readback_rgba_bytes: u64,
|
||||
surface_rgba_bytes: u64,
|
||||
}
|
||||
|
||||
fn spawn_sidecar(kind: &str, profile_data_dir: &PathBuf) -> Result<Child, 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)
|
||||
.arg("--rendering-context")
|
||||
.arg(kind)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
Ok(command.spawn()?)
|
||||
}
|
||||
|
||||
fn drive_bench(
|
||||
stdin: &mut ChildStdin,
|
||||
reader: &mut BufReader<ChildStdout>,
|
||||
kind: &str,
|
||||
tab: &TabId,
|
||||
profile_id: &ProfileId,
|
||||
url: &str,
|
||||
frames: u32,
|
||||
) -> Result<BenchOutcome, Box<dyn Error>> {
|
||||
let mut summaries = Vec::new();
|
||||
let mut surface_handles = Vec::new();
|
||||
let mut current_surface_ids = Vec::new();
|
||||
let mut readback_rgba_bytes: u64 = 0;
|
||||
let mut surface_rgba_bytes: u64 = 0;
|
||||
|
||||
let navigate = build_ensure(tab, profile_id, url, 0, 0, false);
|
||||
write_request(stdin, &navigate)?;
|
||||
let response = read_response(reader, RESPONSE_TIMEOUT)?;
|
||||
assert_frame_viewport_report(&response);
|
||||
record_summary(&response, kind, &mut summaries);
|
||||
record_surface_handle(&response, kind, &mut surface_handles);
|
||||
record_current_surface_id(&response, &mut current_surface_ids);
|
||||
record_rgba_bytes(&response, &mut readback_rgba_bytes, &mut surface_rgba_bytes);
|
||||
|
||||
let mut accumulated_scroll = 0;
|
||||
let mut painted_frames = 0;
|
||||
let max_attempts = frames.saturating_mul(4).max(frames + 10);
|
||||
for attempt in 0..max_attempts {
|
||||
if painted_frames >= frames {
|
||||
break;
|
||||
}
|
||||
let scroll_delta_y =
|
||||
if painted_frames % 80 == 79 { -SCROLL_STEP_PX * 60 } else { SCROLL_STEP_PX };
|
||||
accumulated_scroll += scroll_delta_y;
|
||||
let request = build_ensure(tab, profile_id, url, 0, scroll_delta_y, true);
|
||||
write_request(stdin, &request)?;
|
||||
let response = read_response(reader, RESPONSE_TIMEOUT)?;
|
||||
if let Some(error) = response.error.as_ref() {
|
||||
return Err(format!("sidecar error at attempt {attempt}: {error}").into());
|
||||
}
|
||||
if response.frame.is_some() {
|
||||
painted_frames += 1;
|
||||
}
|
||||
assert_frame_viewport_report(&response);
|
||||
record_summary(&response, kind, &mut summaries);
|
||||
record_surface_handle(&response, kind, &mut surface_handles);
|
||||
record_current_surface_id(&response, &mut current_surface_ids);
|
||||
record_rgba_bytes(&response, &mut readback_rgba_bytes, &mut surface_rgba_bytes);
|
||||
}
|
||||
assert_eq!(painted_frames, frames, "bench did not receive the requested painted frame count");
|
||||
let _ = accumulated_scroll;
|
||||
for _ in 0..5 {
|
||||
if !summaries.is_empty() {
|
||||
break;
|
||||
}
|
||||
let poll = build_poll(tab);
|
||||
write_request(stdin, &poll)?;
|
||||
let response = read_response(reader, RESPONSE_TIMEOUT)?;
|
||||
record_summary(&response, kind, &mut summaries);
|
||||
}
|
||||
|
||||
Ok(BenchOutcome {
|
||||
summaries,
|
||||
surface_handles,
|
||||
current_surface_ids,
|
||||
readback_rgba_bytes,
|
||||
surface_rgba_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn record_rgba_bytes(
|
||||
response: &LiveResponse,
|
||||
readback_rgba_bytes: &mut u64,
|
||||
surface_rgba_bytes: &mut u64,
|
||||
) {
|
||||
let rgba_byte_count = response.frame.as_ref().map_or(0, |frame| frame.rgba_byte_count as u64);
|
||||
if rgba_byte_count > 0 {
|
||||
*readback_rgba_bytes += rgba_byte_count;
|
||||
} else if response.current_surface_id.is_some() {
|
||||
*surface_rgba_bytes += rgba_byte_count;
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_frame_viewport_report(response: &LiveResponse) {
|
||||
let Some(frame) = response.frame.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let dpr = if frame.device_pixel_ratio.is_finite() && frame.device_pixel_ratio > 0.0 {
|
||||
frame.device_pixel_ratio
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let expected_width = ((frame.width as f32) / dpr).round().max(1.0) as u32;
|
||||
let expected_height = ((frame.height as f32) / dpr).round().max(1.0) as u32;
|
||||
|
||||
assert_eq!(
|
||||
frame.css_viewport_width, expected_width,
|
||||
"CSS viewport width must match physical width divided by DPR",
|
||||
);
|
||||
assert_eq!(
|
||||
frame.css_viewport_height, expected_height,
|
||||
"CSS viewport height must match physical height divided by DPR",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_surface_handle(
|
||||
response: &LiveResponse,
|
||||
kind: &str,
|
||||
surface_handles: &mut Vec<BenchSurfaceHandle>,
|
||||
) {
|
||||
if let Some(handle) = response.surface_handle {
|
||||
eprintln!(
|
||||
"[iosurface {kind}] new surface_id=0x{:x} mach_port=0x{:x} {}x{}",
|
||||
handle.surface_id, handle.mach_port_name, handle.width, handle.height,
|
||||
);
|
||||
surface_handles.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
fn record_current_surface_id(response: &LiveResponse, current_surface_ids: &mut Vec<u64>) {
|
||||
if let Some(surface_id) = response.current_surface_id {
|
||||
current_surface_ids.push(surface_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_surface_handles(kind: &str, surface_handles: &[BenchSurfaceHandle]) {
|
||||
eprintln!(
|
||||
"\n=== ELY_PERF_KIND={kind} iosurface_imports={} (one per unique surface) ===",
|
||||
surface_handles.len()
|
||||
);
|
||||
for (index, handle) in surface_handles.iter().enumerate() {
|
||||
eprintln!(
|
||||
"{:<4} surface_id=0x{:x} mach_port=0x{:x} {}x{}",
|
||||
index, handle.surface_id, handle.mach_port_name, handle.width, handle.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_current_surface_summary(kind: &str, current_surface_ids: &[u64]) {
|
||||
use std::collections::BTreeMap;
|
||||
let mut counts: BTreeMap<u64, u32> = BTreeMap::new();
|
||||
for id in current_surface_ids {
|
||||
*counts.entry(*id).or_default() += 1;
|
||||
}
|
||||
eprintln!("\n=== ELY_PERF_KIND={kind} current_surface_id histogram (per-frame selector) ===",);
|
||||
for (surface_id, count) in counts.iter() {
|
||||
eprintln!("surface_id=0x{:x} frames={}", surface_id, count);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ensure(
|
||||
tab: &TabId,
|
||||
profile_id: &ProfileId,
|
||||
url: &str,
|
||||
scroll_dx: i32,
|
||||
scroll_dy: i32,
|
||||
include_hover: bool,
|
||||
) -> String {
|
||||
let hover_x = if include_hover { Some(256u32) } else { None };
|
||||
let hover_y = if include_hover { Some(256u32) } else { None };
|
||||
let scroll_point = if scroll_dx != 0 || scroll_dy != 0 { Some((256u32, 256u32)) } else { None };
|
||||
let hover_x_json = match hover_x {
|
||||
Some(value) => format!("{value}"),
|
||||
None => "null".to_string(),
|
||||
};
|
||||
let hover_y_json = match hover_y {
|
||||
Some(value) => format!("{value}"),
|
||||
None => "null".to_string(),
|
||||
};
|
||||
let scroll_point_x_json = match scroll_point {
|
||||
Some((x, _)) => format!("{x}"),
|
||||
None => "null".to_string(),
|
||||
};
|
||||
let scroll_point_y_json = match scroll_point {
|
||||
Some((_, y)) => format!("{y}"),
|
||||
None => "null".to_string(),
|
||||
};
|
||||
format!(
|
||||
r#"{{"type":"ensure","tab_id":"{tab}","profile_id":"{profile}","url":{url},"width":{w},"height":{h},"page_zoom_percent":100,"scroll_delta_x":{dx},"scroll_delta_y":{dy},"scroll_point_x":{sx},"scroll_point_y":{sy},"click_x":null,"click_y":null,"hover_x":{hx},"hover_y":{hy},"typed_text":null,"site_permissions":[]}}"#,
|
||||
tab = tab.as_str(),
|
||||
profile = profile_id.as_str(),
|
||||
url = serde_json::to_string(url).unwrap_or_else(|_| "\"\"".to_string()),
|
||||
w = VIEWPORT_WIDTH,
|
||||
h = VIEWPORT_HEIGHT,
|
||||
dx = scroll_dx,
|
||||
dy = scroll_dy,
|
||||
sx = scroll_point_x_json,
|
||||
sy = scroll_point_y_json,
|
||||
hx = hover_x_json,
|
||||
hy = hover_y_json,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_poll(tab: &TabId) -> String {
|
||||
format!(r#"{{"type":"poll","tab_id":"{}"}}"#, tab.as_str())
|
||||
}
|
||||
|
||||
fn write_request(stdin: &mut ChildStdin, request: &str) -> Result<(), Box<dyn Error>> {
|
||||
stdin.write_all(request.as_bytes())?;
|
||||
stdin.write_all(b"\n")?;
|
||||
stdin.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_response(
|
||||
reader: &mut BufReader<ChildStdout>,
|
||||
timeout: Duration,
|
||||
) -> Result<LiveResponse, Box<dyn Error>> {
|
||||
Ok(read_response_with_bytes(reader, timeout)?.0)
|
||||
}
|
||||
|
||||
fn read_response_with_bytes(
|
||||
reader: &mut BufReader<ChildStdout>,
|
||||
timeout: Duration,
|
||||
) -> Result<(LiveResponse, Vec<u8>), Box<dyn Error>> {
|
||||
let started_at = Instant::now();
|
||||
let mut json_line = String::new();
|
||||
loop {
|
||||
json_line.clear();
|
||||
let read_bytes = reader.read_line(&mut json_line)?;
|
||||
if read_bytes == 0 {
|
||||
return Err("sidecar closed stdout".into());
|
||||
}
|
||||
if json_line.trim().is_empty() {
|
||||
if started_at.elapsed() >= timeout {
|
||||
return Err("sidecar response timeout".into());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
let response: LiveResponse = serde_json::from_str(json_line.trim_end())?;
|
||||
let mut rgba = Vec::new();
|
||||
if let Some(frame) = response.frame.as_ref()
|
||||
&& frame.rgba_byte_count > 0
|
||||
{
|
||||
rgba.resize(frame.rgba_byte_count, 0);
|
||||
reader.read_exact(&mut rgba)?;
|
||||
}
|
||||
Ok((response, rgba))
|
||||
}
|
||||
|
||||
fn record_summary(response: &LiveResponse, kind: &str, summaries: &mut Vec<FramePerfSummary>) {
|
||||
if let Some(perf) = response.perf.as_ref() {
|
||||
assert_eq!(perf.context, kind, "sidecar context label must match requested kind");
|
||||
eprintln!(
|
||||
"[perf {kind}] window={} paint p50/p95/p99={}/{}/{} encode {}/{}/{} write {}/{}/{} total {}/{}/{} (µs)",
|
||||
perf.window,
|
||||
perf.paint_p50_us,
|
||||
perf.paint_p95_us,
|
||||
perf.paint_p99_us,
|
||||
perf.encode_p50_us,
|
||||
perf.encode_p95_us,
|
||||
perf.encode_p99_us,
|
||||
perf.write_p50_us,
|
||||
perf.write_p95_us,
|
||||
perf.write_p99_us,
|
||||
perf.total_p50_us,
|
||||
perf.total_p95_us,
|
||||
perf.total_p99_us,
|
||||
);
|
||||
summaries.push(perf.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn print_summaries(kind: &str, frames: u32, summaries: &[FramePerfSummary]) {
|
||||
eprintln!("\n=== ELY_PERF_KIND={kind} frames={frames} windows={} ===", summaries.len());
|
||||
for summary in summaries {
|
||||
eprintln!(
|
||||
"win={} paint={}/{}/{} encode={}/{}/{} write={}/{}/{} total={}/{}/{}",
|
||||
summary.window,
|
||||
summary.paint_p50_us,
|
||||
summary.paint_p95_us,
|
||||
summary.paint_p99_us,
|
||||
summary.encode_p50_us,
|
||||
summary.encode_p95_us,
|
||||
summary.encode_p99_us,
|
||||
summary.write_p50_us,
|
||||
summary.write_p95_us,
|
||||
summary.write_p99_us,
|
||||
summary.total_p50_us,
|
||||
summary.total_p95_us,
|
||||
summary.total_p99_us,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup(profile_data_dir: &PathBuf) -> Result<(), Box<dyn Error>> {
|
||||
match fs::remove_dir_all(profile_data_dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
fs,
|
||||
io::BufReader,
|
||||
process::{ChildStdin, ChildStdout},
|
||||
};
|
||||
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
|
||||
use super::{
|
||||
RESPONSE_TIMEOUT, build_ensure, cleanup, read_response_with_bytes, spawn_sidecar, write_request,
|
||||
};
|
||||
|
||||
const SOLID_RED_DATA_URL: &str =
|
||||
"data:text/html,<body style=\"margin:0;background:%23ff0000;height:4000px\">";
|
||||
const SOLID_BLUE_DATA_URL: &str =
|
||||
"data:text/html,<body style=\"margin:0;background:%230000ff;height:4000px\">";
|
||||
|
||||
#[test]
|
||||
#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"]
|
||||
fn red_data_url_yields_red_rgba() -> Result<(), Box<dyn Error>> {
|
||||
assert_solid_color_renders("software", SOLID_RED_DATA_URL, ColorTarget::Red)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"]
|
||||
fn blue_data_url_yields_blue_rgba() -> Result<(), Box<dyn Error>> {
|
||||
assert_solid_color_renders("software", SOLID_BLUE_DATA_URL, ColorTarget::Blue)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ColorTarget {
|
||||
Red,
|
||||
Blue,
|
||||
}
|
||||
|
||||
impl ColorTarget {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
ColorTarget::Red => "red",
|
||||
ColorTarget::Blue => "blue",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_solid_color_renders(
|
||||
kind: &str,
|
||||
url: &str,
|
||||
target: ColorTarget,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let profile_id = ProfileId::new();
|
||||
let tab = TabId::new();
|
||||
let profile_data_dir = env::temp_dir().join(format!(
|
||||
"ely-pixel-{}-{}-{}",
|
||||
std::process::id(),
|
||||
target.label(),
|
||||
profile_id.as_str(),
|
||||
));
|
||||
fs::create_dir_all(&profile_data_dir)?;
|
||||
|
||||
let mut child = spawn_sidecar(kind, &profile_data_dir)?;
|
||||
let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?;
|
||||
let stdout = child.stdout.take().ok_or("sidecar stdout missing")?;
|
||||
let mut reader = BufReader::new(stdout);
|
||||
let outcome = drive_solid_color_render(&mut stdin, &mut reader, &tab, &profile_id, url, target);
|
||||
|
||||
drop(stdin);
|
||||
let _ = child.wait();
|
||||
cleanup(&profile_data_dir)?;
|
||||
outcome
|
||||
}
|
||||
|
||||
fn drive_solid_color_render(
|
||||
stdin: &mut ChildStdin,
|
||||
reader: &mut BufReader<ChildStdout>,
|
||||
tab: &TabId,
|
||||
profile_id: &ProfileId,
|
||||
url: &str,
|
||||
target: ColorTarget,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let mut bytes = Vec::new();
|
||||
let mut report = None;
|
||||
for iteration in 0..30 {
|
||||
let scroll_y = if iteration == 0 {
|
||||
0
|
||||
} else if iteration % 2 == 1 {
|
||||
1
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
let request = build_ensure(tab, profile_id, url, 0, scroll_y, false);
|
||||
write_request(stdin, &request)?;
|
||||
let (response, response_bytes) = read_response_with_bytes(reader, RESPONSE_TIMEOUT)?;
|
||||
if let Some(error) = response.error.as_ref() {
|
||||
return Err(format!("sidecar error: {error}").into());
|
||||
}
|
||||
if let Some(frame_report) = response.frame {
|
||||
if !response_bytes.is_empty()
|
||||
&& sample_matches_target(
|
||||
&response_bytes,
|
||||
frame_report.width,
|
||||
frame_report.height,
|
||||
target,
|
||||
)
|
||||
{
|
||||
report = Some(frame_report);
|
||||
bytes = response_bytes;
|
||||
break;
|
||||
}
|
||||
if !response_bytes.is_empty() {
|
||||
bytes = response_bytes;
|
||||
report = Some(frame_report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let report = report.ok_or("never received a frame with bytes")?;
|
||||
let width = report.width as usize;
|
||||
let height = report.height as usize;
|
||||
assert_eq!(bytes.len(), width * height * 4, "rgba byte count must match width * height * 4",);
|
||||
|
||||
let mut samples = Vec::new();
|
||||
for fy in [1, 2, 3] {
|
||||
for fx in [1, 2, 3] {
|
||||
let x = width * fx / 4;
|
||||
let y = height * fy / 4;
|
||||
let idx = (y * width + x) * 4;
|
||||
samples.push((x, y, bytes[idx], bytes[idx + 1], bytes[idx + 2], bytes[idx + 3]));
|
||||
}
|
||||
}
|
||||
eprintln!("[pixel sample {}] {:?}", target.label(), samples);
|
||||
|
||||
let hits =
|
||||
samples.iter().filter(|(_x, _y, r, g, b, _a)| matches_color(*r, *g, *b, target)).count();
|
||||
assert!(
|
||||
hits >= 5,
|
||||
"expected at least 5/9 center-quadrant pixels to be {} after rendering {}; got samples {:?}",
|
||||
target.label(),
|
||||
url,
|
||||
samples,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sample_matches_target(bytes: &[u8], width: u32, height: u32, target: ColorTarget) -> bool {
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
if bytes.len() < w * h * 4 || w == 0 || h == 0 {
|
||||
return false;
|
||||
}
|
||||
let cx = w / 2;
|
||||
let cy = h / 2;
|
||||
let idx = (cy * w + cx) * 4;
|
||||
matches_color(bytes[idx], bytes[idx + 1], bytes[idx + 2], target)
|
||||
}
|
||||
|
||||
fn matches_color(r: u8, g: u8, b: u8, target: ColorTarget) -> bool {
|
||||
match target {
|
||||
ColorTarget::Red => r >= 200 && g <= 60 && b <= 60,
|
||||
ColorTarget::Blue => r <= 60 && g <= 60 && b >= 200,
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
#![cfg(feature = "servo-engine")]
|
||||
|
||||
use std::{collections::BTreeSet, error::Error, fs, path::PathBuf, process::Command};
|
||||
|
||||
use ely_domain::ProfileId;
|
||||
|
||||
#[path = "sidecar/site_cases.rs"]
|
||||
mod site_cases;
|
||||
#[path = "sidecar/support.rs"]
|
||||
mod support;
|
||||
|
||||
use support::*;
|
||||
|
||||
#[test]
|
||||
fn sidecar_prd_reference_cases_cover_prd_urls() -> Result<(), Box<dyn Error>> {
|
||||
let prd = fs::read_to_string(prd_path())?;
|
||||
let prd_urls = prd_reference_urls(&prd);
|
||||
let covered_urls = PRD_REFERENCE_SITE_COMPATIBILITY_CASES
|
||||
.iter()
|
||||
.map(|case| normalized_url(case.url))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let missing_urls = prd_urls
|
||||
.iter()
|
||||
.filter(|url| !covered_urls.contains(url.as_str()))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(missing_urls.is_empty(), "missing PRD sidecar smoke cases: {missing_urls:?}");
|
||||
assert_eq!(prd_urls.len(), PRD_REFERENCE_SITE_COMPATIBILITY_CASES.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_opens_and_renders_prd_sites_to_rgba_files() -> Result<(), Box<dyn Error>> {
|
||||
for case in PRD_SITE_COMPATIBILITY_CASES {
|
||||
for size in PRD_SITE_COMPATIBILITY_SIZES {
|
||||
snapshot_prd_site(case, *size, ScrollOffset::ZERO)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_report_uses_requested_profile_id() -> Result<(), Box<dyn Error>> {
|
||||
let profile_id = ProfileId::new();
|
||||
let profile_data_dir = std::env::temp_dir().join(format!(
|
||||
"ely-servo-sidecar-profile-test-{}-{}",
|
||||
std::process::id(),
|
||||
profile_id.as_str()
|
||||
));
|
||||
let rgba_path = std::env::temp_dir().join(format!(
|
||||
"ely-servo-sidecar-profile-test-{}-{}.rgba",
|
||||
std::process::id(),
|
||||
profile_id.as_str()
|
||||
));
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"))
|
||||
.arg("snapshot")
|
||||
.arg("--url")
|
||||
.arg("data:text/html,%3Ctitle%3EProfile%20Probe%3C%2Ftitle%3EProfile%20Probe")
|
||||
.arg("--profile-id")
|
||||
.arg(profile_id.as_str())
|
||||
.arg("--profile-data-dir")
|
||||
.arg(&profile_data_dir)
|
||||
.arg("--rgba-out")
|
||||
.arg(&rgba_path)
|
||||
.arg("--width")
|
||||
.arg("64")
|
||||
.arg("--height")
|
||||
.arg("64")
|
||||
.output()?;
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let report: serde_json::Value = serde_json::from_slice(&output.stdout)?;
|
||||
assert_eq!(
|
||||
report.get("profile_id").and_then(serde_json::Value::as_str),
|
||||
Some(profile_id.as_str())
|
||||
);
|
||||
|
||||
remove_file_if_present(rgba_path)?;
|
||||
remove_dir_if_present(profile_data_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prd_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("PRD.md")
|
||||
}
|
||||
|
||||
fn remove_file_if_present(path: PathBuf) -> Result<(), Box<dyn Error>> {
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_dir_if_present(path: PathBuf) -> Result<(), Box<dyn Error>> {
|
||||
match fs::remove_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn prd_reference_urls(prd: &str) -> Vec<String> {
|
||||
prd.lines()
|
||||
.filter(|line| line.starts_with("[R"))
|
||||
.filter_map(|line| {
|
||||
let start = line.find("https://")?;
|
||||
let url = line[start..].split_whitespace().next()?;
|
||||
Some(normalized_url(url))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalized_url(url: &str) -> String {
|
||||
url.trim().trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_opens_and_renders_prd_reference_sites_to_rgba_files() -> Result<(), Box<dyn Error>> {
|
||||
for case in PRD_REFERENCE_SITE_COMPATIBILITY_CASES {
|
||||
snapshot_prd_site(case, PRD_REFERENCE_SITE_SIZE, ScrollOffset::ZERO)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_scrolls_prd_site_with_servo_input() -> Result<(), Box<dyn Error>> {
|
||||
let scrolled_report =
|
||||
snapshot_prd_site(&SERVO_SCROLL_SITE, SERVO_SCROLL_SIZE, SERVO_SCROLL_OFFSET)?;
|
||||
|
||||
assert_eq!(report_field_as_i64(&scrolled_report, "scroll_x")?, SERVO_SCROLL_OFFSET.x);
|
||||
assert_eq!(report_field_as_i64(&scrolled_report, "scroll_y")?, SERVO_SCROLL_OFFSET.y);
|
||||
assert_eq!(report_field_as_u64(&scrolled_report, "width")?, SERVO_SCROLL_SIZE.width);
|
||||
assert!(report_field_as_bool(&scrolled_report, "scroll_changed_frame")?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_clicks_page_with_servo_mouse_input() -> Result<(), Box<dyn Error>> {
|
||||
let initial_report = snapshot_click_probe(None)?;
|
||||
let clicked_report = snapshot_click_probe(Some(SERVO_CLICK_POINT))?;
|
||||
|
||||
assert_eq!(report_field_as_u64(&clicked_report, "click_x")?, SERVO_CLICK_POINT.x);
|
||||
assert_eq!(report_field_as_u64(&clicked_report, "click_y")?, SERVO_CLICK_POINT.y);
|
||||
assert!(report_field_as_bool(&clicked_report, "click_changed_frame")?);
|
||||
assert_ne!(
|
||||
report_field_as_u64(&initial_report, "sample_hash")?,
|
||||
report_field_as_u64(&clicked_report, "sample_hash")?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_drags_page_with_servo_mouse_input() -> Result<(), Box<dyn Error>> {
|
||||
let initial_report = snapshot_drag_probe(None)?;
|
||||
let drag_points = DragPoints { from: SERVO_DRAG_FROM, to: SERVO_DRAG_TO };
|
||||
let dragged_report = snapshot_drag_probe(Some(drag_points))?;
|
||||
|
||||
assert_eq!(report_field_as_u64(&dragged_report, "drag_from_x")?, SERVO_DRAG_FROM.x);
|
||||
assert_eq!(report_field_as_u64(&dragged_report, "drag_from_y")?, SERVO_DRAG_FROM.y);
|
||||
assert_eq!(report_field_as_u64(&dragged_report, "drag_to_x")?, SERVO_DRAG_TO.x);
|
||||
assert_eq!(report_field_as_u64(&dragged_report, "drag_to_y")?, SERVO_DRAG_TO.y);
|
||||
assert!(report_field_as_bool(&dragged_report, "drag_changed_frame")?);
|
||||
assert_ne!(
|
||||
report_field_as_u64(&initial_report, "sample_hash")?,
|
||||
report_field_as_u64(&dragged_report, "sample_hash")?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_touches_page_with_servo_touch_input() -> Result<(), Box<dyn Error>> {
|
||||
let initial_report = snapshot_touch_probe(None)?;
|
||||
let touched_report = snapshot_touch_probe(Some(SERVO_TOUCH_POINT))?;
|
||||
|
||||
assert_eq!(report_field_as_u64(&touched_report, "touch_x")?, SERVO_TOUCH_POINT.x);
|
||||
assert_eq!(report_field_as_u64(&touched_report, "touch_y")?, SERVO_TOUCH_POINT.y);
|
||||
assert!(report_field_as_bool(&touched_report, "touch_changed_frame")?);
|
||||
assert_ne!(
|
||||
report_field_as_u64(&initial_report, "sample_hash")?,
|
||||
report_field_as_u64(&touched_report, "sample_hash")?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_types_text_with_servo_keyboard_input() -> Result<(), Box<dyn Error>> {
|
||||
let initial_report = snapshot_text_probe(None)?;
|
||||
let typed_report = snapshot_text_probe(Some(SERVO_TEXT_VALUE))?;
|
||||
|
||||
assert_eq!(
|
||||
report_field_as_u64(&typed_report, "typed_text_byte_count")?,
|
||||
SERVO_TEXT_VALUE.len() as u64
|
||||
);
|
||||
assert!(report_field_as_bool(&typed_report, "text_changed_frame")?);
|
||||
assert_ne!(
|
||||
report_field_as_u64(&initial_report, "sample_hash")?,
|
||||
report_field_as_u64(&typed_report, "sample_hash")?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
pub(super) const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
|
||||
PrdSiteCompatibilityCase { url: "https://github.com", title_fragment: "GitHub" },
|
||||
PrdSiteCompatibilityCase { url: "https://example.com", title_fragment: "Example Domain" },
|
||||
PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" },
|
||||
];
|
||||
|
||||
pub(super) const PRD_REFERENCE_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://blog.google/products-and-platforms/products/chrome/new-chrome-productivity-features/",
|
||||
title_fragment: "Chrome",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://www.microsoft.com/en-us/edge/features/vertical-tabs",
|
||||
title_fragment: "Microsoft Edge",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://resources.arc.net/hc/en-us/articles/19230755904151-Favorites-Top-Tabs-Across-Every-Space",
|
||||
title_fragment: "Favorites",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://resources.arc.net/hc/en-us/articles/19228855311127-Auto-Archive-Clean-as-you-go",
|
||||
title_fragment: "Auto Archive",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://vivaldi.com/features/workspaces/",
|
||||
title_fragment: "Workspaces",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://help.vivaldi.com/desktop/tabs/tab-tiling/",
|
||||
title_fragment: "Tab Tiling",
|
||||
},
|
||||
PrdSiteCompatibilityCase { url: "https://www.gpui.rs/", title_fragment: "gpui" },
|
||||
PrdSiteCompatibilityCase { url: "https://docs.rs/gpui", title_fragment: "gpui" },
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://zed.dev/blog/videogame",
|
||||
title_fragment: "Leveraging Rust",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://github.com/longbridge/gpui-component/",
|
||||
title_fragment: "gpui-component",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://github.com/zed-industries/awesome-gpui/",
|
||||
title_fragment: "awesome-gpui",
|
||||
},
|
||||
PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" },
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://servo.org/blog/2026/04/13/servo-0.1.0-release/",
|
||||
title_fragment: "Servo",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://developers.cloudflare.com/d1/",
|
||||
title_fragment: "Cloudflare",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://developers.cloudflare.com/workers/platform/storage-options/",
|
||||
title_fragment: "Cloudflare",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://developers.cloudflare.com/kv/concepts/how-kv-works/",
|
||||
title_fragment: "Cloudflare",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://better-auth.com/blog/1-5",
|
||||
title_fragment: "Better Auth",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://developers.cloudflare.com/d1/platform/limits/",
|
||||
title_fragment: "Cloudflare",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://component-model.bytecodealliance.org/",
|
||||
title_fragment: "WebAssembly Component Model",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://docs.wasmtime.dev/api/wasmtime/component/index.html",
|
||||
title_fragment: "wasmtime",
|
||||
},
|
||||
PrdSiteCompatibilityCase {
|
||||
url: "https://docs.wasmtime.dev/security.html",
|
||||
title_fragment: "Wasmtime",
|
||||
},
|
||||
];
|
||||
|
||||
pub(super) const PRD_SITE_COMPATIBILITY_SIZES: &[FrameSize] = &[
|
||||
FrameSize { width: 640, height: 480 },
|
||||
FrameSize { width: 934, height: 657 },
|
||||
FrameSize { width: 1614, height: 980 },
|
||||
];
|
||||
pub(super) const PRD_REFERENCE_SITE_SIZE: FrameSize = FrameSize { width: 934, height: 657 };
|
||||
pub(super) const SERVO_SCROLL_SITE: PrdSiteCompatibilityCase =
|
||||
PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" };
|
||||
pub(super) const SERVO_SCROLL_SIZE: FrameSize = FrameSize { width: 934, height: 657 };
|
||||
pub(super) const SERVO_SCROLL_OFFSET: ScrollOffset = ScrollOffset { x: 0, y: 480 };
|
||||
pub(super) const SERVO_CLICK_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";
|
||||
pub(super) const SERVO_CLICK_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
|
||||
pub(super) const SERVO_CLICK_POINT: ClickPoint = ClickPoint { x: 160, y: 120 };
|
||||
pub(super) const SERVO_DRAG_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";
|
||||
pub(super) const SERVO_DRAG_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
|
||||
pub(super) const SERVO_DRAG_FROM: ClickPoint = ClickPoint { x: 160, y: 120 };
|
||||
pub(super) const SERVO_DRAG_TO: ClickPoint = ClickPoint { x: 320, y: 120 };
|
||||
pub(super) const SERVO_TOUCH_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%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E";
|
||||
pub(super) const SERVO_TOUCH_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
|
||||
pub(super) const SERVO_TOUCH_POINT: ClickPoint = ClickPoint { x: 160, y: 120 };
|
||||
pub(super) const SERVO_TEXT_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";
|
||||
pub(super) const SERVO_TEXT_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
|
||||
pub(super) const SERVO_TEXT_POINT: ClickPoint = ClickPoint { x: 160, y: 120 };
|
||||
pub(super) const SERVO_TEXT_VALUE: &str = "ely42";
|
||||
|
||||
pub(super) struct PrdSiteCompatibilityCase {
|
||||
pub(super) url: &'static str,
|
||||
pub(super) title_fragment: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct FrameSize {
|
||||
pub(super) width: u64,
|
||||
pub(super) height: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ScrollOffset {
|
||||
pub(super) x: i64,
|
||||
pub(super) y: i64,
|
||||
}
|
||||
|
||||
impl ScrollOffset {
|
||||
pub(super) const ZERO: Self = Self { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ClickPoint {
|
||||
pub(super) x: u64,
|
||||
pub(super) y: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct DragPoints {
|
||||
pub(super) from: ClickPoint,
|
||||
pub(super) to: ClickPoint,
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
use std::{
|
||||
error::Error,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Output, Stdio},
|
||||
sync::Mutex,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_domain::ProfileId;
|
||||
|
||||
pub(super) use super::site_cases::{
|
||||
ClickPoint, DragPoints, FrameSize, PRD_REFERENCE_SITE_COMPATIBILITY_CASES,
|
||||
PRD_REFERENCE_SITE_SIZE, PRD_SITE_COMPATIBILITY_CASES, PRD_SITE_COMPATIBILITY_SIZES,
|
||||
PrdSiteCompatibilityCase, SERVO_CLICK_POINT, SERVO_DRAG_FROM, SERVO_DRAG_TO,
|
||||
SERVO_SCROLL_OFFSET, SERVO_SCROLL_SITE, SERVO_SCROLL_SIZE, SERVO_TEXT_VALUE, SERVO_TOUCH_POINT,
|
||||
ScrollOffset,
|
||||
};
|
||||
use super::site_cases::{
|
||||
SERVO_CLICK_SIZE, SERVO_CLICK_URL, SERVO_DRAG_SIZE, SERVO_DRAG_URL, SERVO_TEXT_POINT,
|
||||
SERVO_TEXT_SIZE, SERVO_TEXT_URL, SERVO_TOUCH_SIZE, SERVO_TOUCH_URL,
|
||||
};
|
||||
|
||||
pub(super) const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
|
||||
const SIDECAR_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20);
|
||||
const SIDECAR_COMMAND_COOLDOWN: Duration = Duration::from_millis(750);
|
||||
const SIDECAR_RETRY_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const SIDECAR_MAX_ATTEMPTS: usize = 3;
|
||||
static SIDECAR_COMMAND_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct SnapshotInput<'a> {
|
||||
click_point: Option<ClickPoint>,
|
||||
drag_points: Option<DragPoints>,
|
||||
touch_point: Option<ClickPoint>,
|
||||
typed_text: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub(super) fn snapshot_prd_site(
|
||||
case: &PrdSiteCompatibilityCase,
|
||||
size: FrameSize,
|
||||
scroll_offset: ScrollOffset,
|
||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||
let site_name = case
|
||||
.url
|
||||
.chars()
|
||||
.map(|character| if character.is_ascii_alphanumeric() { character } else { '-' })
|
||||
.collect::<String>();
|
||||
let output_path = std::env::temp_dir().join(format!(
|
||||
"ely-servo-sidecar-{}-{site_name}-{}x{}-{}-{}.rgba",
|
||||
std::process::id(),
|
||||
size.width,
|
||||
size.height,
|
||||
scroll_offset.x,
|
||||
scroll_offset.y
|
||||
));
|
||||
|
||||
snapshot_prd_site_with_retry(case, &output_path, size, scroll_offset)
|
||||
}
|
||||
|
||||
fn snapshot_prd_site_with_retry(
|
||||
case: &PrdSiteCompatibilityCase,
|
||||
output_path: &Path,
|
||||
size: FrameSize,
|
||||
scroll_offset: ScrollOffset,
|
||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||
for attempt in 0..SIDECAR_MAX_ATTEMPTS {
|
||||
match snapshot_prd_site_once(case, output_path, size, scroll_offset) {
|
||||
Ok(report) => return Ok(report),
|
||||
Err(error) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Err(error),
|
||||
Err(_) => remove_file_if_present(output_path)?,
|
||||
}
|
||||
|
||||
thread::sleep(SIDECAR_RETRY_INTERVAL);
|
||||
}
|
||||
|
||||
Err("sidecar PRD snapshot retry did not produce output".into())
|
||||
}
|
||||
|
||||
fn snapshot_prd_site_once(
|
||||
case: &PrdSiteCompatibilityCase,
|
||||
output_path: &Path,
|
||||
size: FrameSize,
|
||||
scroll_offset: ScrollOffset,
|
||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||
let output = run_sidecar_snapshot_with_retry(
|
||||
case.url,
|
||||
output_path,
|
||||
size,
|
||||
scroll_offset,
|
||||
SnapshotInput::default(),
|
||||
)?;
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{} {}x{}\nstatus: {:?}\nstdout: {}\nstderr: {}",
|
||||
case.url,
|
||||
size.width,
|
||||
size.height,
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let report: serde_json::Value = serde_json::from_slice(&output.stdout)?;
|
||||
assert_report_state_is_renderable(&report)?;
|
||||
assert_eq!(report_field_as_u64(&report, "width")?, size.width, "{}", case.url);
|
||||
assert_eq!(report_field_as_u64(&report, "height")?, size.height, "{}", case.url);
|
||||
assert_eq!(
|
||||
report_field_as_u64(&report, "rgba_byte_count")?,
|
||||
size.width * size.height * 4,
|
||||
"{}",
|
||||
case.url
|
||||
);
|
||||
assert_report_text_contains(&report, "loaded_url", case.url)?;
|
||||
assert_report_text_equals(&report, "requested_url", case.url)?;
|
||||
assert_report_text_contains(&report, "title", case.title_fragment)?;
|
||||
assert!(report_field_as_u64(&report, "non_white_pixel_count")? > 0, "{}", case.url);
|
||||
assert!(
|
||||
report_field_as_u64(&report, "content_pixel_count")? >= MINIMUM_CONTENT_PIXELS,
|
||||
"{}",
|
||||
case.url
|
||||
);
|
||||
assert!(report_field_as_u64(&report, "sample_hash")? > 0, "{}", case.url);
|
||||
assert_eq!(std::fs::metadata(output_path)?.len(), size.width * size.height * 4);
|
||||
|
||||
log_prd_report(&report, case, size)?;
|
||||
|
||||
std::fs::remove_file(output_path)?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn remove_file_if_present(path: &Path) -> Result<(), Box<dyn Error>> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn snapshot_click_probe(
|
||||
click_point: Option<ClickPoint>,
|
||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||
snapshot_probe(
|
||||
SERVO_CLICK_URL,
|
||||
SERVO_CLICK_SIZE,
|
||||
"click",
|
||||
SnapshotInput { click_point, ..SnapshotInput::default() },
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn snapshot_drag_probe(
|
||||
drag_points: Option<DragPoints>,
|
||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||
snapshot_probe(
|
||||
SERVO_DRAG_URL,
|
||||
SERVO_DRAG_SIZE,
|
||||
"drag",
|
||||
SnapshotInput { drag_points, ..SnapshotInput::default() },
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn snapshot_touch_probe(
|
||||
touch_point: Option<ClickPoint>,
|
||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||
snapshot_probe(
|
||||
SERVO_TOUCH_URL,
|
||||
SERVO_TOUCH_SIZE,
|
||||
"touch",
|
||||
SnapshotInput { touch_point, ..SnapshotInput::default() },
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn snapshot_text_probe(
|
||||
typed_text: Option<&str>,
|
||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||
snapshot_probe(
|
||||
SERVO_TEXT_URL,
|
||||
SERVO_TEXT_SIZE,
|
||||
"text",
|
||||
SnapshotInput {
|
||||
click_point: typed_text.map(|_| SERVO_TEXT_POINT),
|
||||
typed_text,
|
||||
..SnapshotInput::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn snapshot_probe(
|
||||
url: &str,
|
||||
size: FrameSize,
|
||||
label: &'static str,
|
||||
input: SnapshotInput<'_>,
|
||||
) -> Result<serde_json::Value, Box<dyn Error>> {
|
||||
let output_path = std::env::temp_dir().join(format!(
|
||||
"ely-servo-sidecar-{}-{label}-{}x{}.rgba",
|
||||
std::process::id(),
|
||||
size.width,
|
||||
size.height
|
||||
));
|
||||
|
||||
if output_path.exists() {
|
||||
std::fs::remove_file(&output_path)?;
|
||||
}
|
||||
|
||||
let output =
|
||||
run_sidecar_snapshot_with_retry(url, &output_path, size, ScrollOffset::ZERO, input)?;
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{label} probe\nstatus: {:?}\nstdout: {}\nstderr: {}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let report: serde_json::Value = serde_json::from_slice(&output.stdout)?;
|
||||
assert_eq!(report_field_as_u64(&report, "width")?, size.width);
|
||||
assert_eq!(report_field_as_u64(&report, "height")?, size.height);
|
||||
assert!(report_field_as_u64(&report, "content_pixel_count")? > 0);
|
||||
assert_eq!(std::fs::metadata(&output_path)?.len(), size.width * size.height * 4);
|
||||
|
||||
std::fs::remove_file(&output_path)?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn run_sidecar_snapshot(
|
||||
site_url: &str,
|
||||
output_path: &Path,
|
||||
size: FrameSize,
|
||||
scroll_offset: ScrollOffset,
|
||||
input: SnapshotInput<'_>,
|
||||
) -> Result<Output, Box<dyn Error>> {
|
||||
let _guard = SIDECAR_COMMAND_LOCK
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("sidecar command lock poisoned"))?;
|
||||
let profile_id = ProfileId::new();
|
||||
let profile_data_dir = temporary_profile_data_dir(&profile_id);
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"));
|
||||
command
|
||||
.arg("snapshot")
|
||||
.arg("--url")
|
||||
.arg(site_url)
|
||||
.arg("--profile-id")
|
||||
.arg(profile_id.as_str())
|
||||
.arg("--profile-data-dir")
|
||||
.arg(&profile_data_dir)
|
||||
.arg("--rgba-out")
|
||||
.arg(output_path)
|
||||
.arg("--width")
|
||||
.arg(size.width.to_string())
|
||||
.arg("--height")
|
||||
.arg(size.height.to_string());
|
||||
if scroll_offset.x != 0 {
|
||||
command.arg("--scroll-x").arg(scroll_offset.x.to_string());
|
||||
}
|
||||
if scroll_offset.y != 0 {
|
||||
command.arg("--scroll-y").arg(scroll_offset.y.to_string());
|
||||
}
|
||||
if let Some(click_point) = input.click_point {
|
||||
command.arg("--click-x").arg(click_point.x.to_string());
|
||||
command.arg("--click-y").arg(click_point.y.to_string());
|
||||
}
|
||||
if let Some(drag_points) = input.drag_points {
|
||||
command.arg("--drag-from-x").arg(drag_points.from.x.to_string());
|
||||
command.arg("--drag-from-y").arg(drag_points.from.y.to_string());
|
||||
command.arg("--drag-to-x").arg(drag_points.to.x.to_string());
|
||||
command.arg("--drag-to-y").arg(drag_points.to.y.to_string());
|
||||
}
|
||||
if let Some(touch_point) = input.touch_point {
|
||||
command.arg("--touch-x").arg(touch_point.x.to_string());
|
||||
command.arg("--touch-y").arg(touch_point.y.to_string());
|
||||
}
|
||||
if let Some(typed_text) = input.typed_text {
|
||||
command.arg("--type-text").arg(typed_text);
|
||||
}
|
||||
|
||||
let mut child = command.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
|
||||
let started_at = Instant::now();
|
||||
loop {
|
||||
if child.try_wait()?.is_some() {
|
||||
let output = child.wait_with_output()?;
|
||||
remove_temporary_dir(&profile_data_dir)?;
|
||||
thread::sleep(SIDECAR_COMMAND_COOLDOWN);
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
if started_at.elapsed() >= SIDECAR_TIMEOUT {
|
||||
terminate_child(child)?;
|
||||
remove_temporary_dir(&profile_data_dir)?;
|
||||
thread::sleep(SIDECAR_COMMAND_COOLDOWN);
|
||||
return Err(format!(
|
||||
"timed out rendering {site_url} at {}x{}",
|
||||
size.width, size.height
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
thread::sleep(SIDECAR_POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn temporary_profile_data_dir(profile_id: &ProfileId) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"ely-servo-sidecar-profile-{}-{}",
|
||||
std::process::id(),
|
||||
profile_id.as_str()
|
||||
))
|
||||
}
|
||||
|
||||
fn remove_temporary_dir(path: &Path) -> Result<(), Box<dyn Error>> {
|
||||
match std::fs::remove_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_sidecar_snapshot_with_retry(
|
||||
site_url: &str,
|
||||
output_path: &std::path::Path,
|
||||
size: FrameSize,
|
||||
scroll_offset: ScrollOffset,
|
||||
input: SnapshotInput<'_>,
|
||||
) -> Result<Output, Box<dyn Error>> {
|
||||
for attempt in 0..SIDECAR_MAX_ATTEMPTS {
|
||||
if output_path.exists() {
|
||||
std::fs::remove_file(output_path)?;
|
||||
}
|
||||
|
||||
match run_sidecar_snapshot(site_url, output_path, size, scroll_offset, input) {
|
||||
Ok(output) if output.status.success() => return Ok(output),
|
||||
Ok(output) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Ok(output),
|
||||
Ok(_output) => {}
|
||||
Err(error) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Err(error),
|
||||
Err(_error) => {}
|
||||
}
|
||||
|
||||
thread::sleep(SIDECAR_RETRY_INTERVAL);
|
||||
}
|
||||
|
||||
Err("sidecar snapshot retry did not produce output".into())
|
||||
}
|
||||
|
||||
fn terminate_child(mut child: Child) -> Result<(), Box<dyn Error>> {
|
||||
match child.kill() {
|
||||
Ok(()) => {
|
||||
let _output = child.wait_with_output()?;
|
||||
Ok(())
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_report_text_equals(
|
||||
report: &serde_json::Value,
|
||||
field: &'static str,
|
||||
expected: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let value = report_field_as_text(report, field)?;
|
||||
if value == expected { Ok(()) } else { Err(format!("{field}: {value}").into()) }
|
||||
}
|
||||
|
||||
fn assert_report_text_contains(
|
||||
report: &serde_json::Value,
|
||||
field: &'static str,
|
||||
fragment: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let value = report_field_as_text(report, field)?;
|
||||
if value.contains(fragment) { Ok(()) } else { Err(format!("{field}: {value}").into()) }
|
||||
}
|
||||
|
||||
fn assert_report_state_is_renderable(report: &serde_json::Value) -> Result<(), Box<dyn Error>> {
|
||||
let state = report_field_as_text(report, "state")?;
|
||||
if matches!(state, "complete" | "loading") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("state: {state}").into())
|
||||
}
|
||||
}
|
||||
|
||||
fn log_prd_report(
|
||||
report: &serde_json::Value,
|
||||
case: &PrdSiteCompatibilityCase,
|
||||
size: FrameSize,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
eprintln!(
|
||||
"prd-live-site servo-sidecar url={} loaded={} title={} state={} size={}x{} content_pixels={} non_white_pixels={} sample_hash={}",
|
||||
case.url,
|
||||
report_field_as_text(report, "loaded_url")?,
|
||||
report_field_as_text(report, "title")?,
|
||||
report_field_as_text(report, "state")?,
|
||||
size.width,
|
||||
size.height,
|
||||
report_field_as_u64(report, "content_pixel_count")?,
|
||||
report_field_as_u64(report, "non_white_pixel_count")?,
|
||||
report_field_as_u64(report, "sample_hash")?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn report_field_as_text<'a>(
|
||||
report: &'a serde_json::Value,
|
||||
field: &'static str,
|
||||
) -> Result<&'a str, Box<dyn Error>> {
|
||||
report
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| format!("missing text report field: {field}").into())
|
||||
}
|
||||
|
||||
pub(super) fn report_field_as_bool(
|
||||
report: &serde_json::Value,
|
||||
field: &'static str,
|
||||
) -> Result<bool, Box<dyn Error>> {
|
||||
report
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.ok_or_else(|| format!("missing boolean report field: {field}").into())
|
||||
}
|
||||
|
||||
pub(super) fn report_field_as_i64(
|
||||
report: &serde_json::Value,
|
||||
field: &'static str,
|
||||
) -> Result<i64, Box<dyn Error>> {
|
||||
report
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.ok_or_else(|| format!("missing signed report field: {field}").into())
|
||||
}
|
||||
|
||||
pub(super) fn report_field_as_u64(
|
||||
report: &serde_json::Value,
|
||||
field: &'static str,
|
||||
) -> Result<u64, Box<dyn Error>> {
|
||||
report
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.ok_or_else(|| format!("missing numeric report field: {field}").into())
|
||||
}
|
||||
@@ -11,9 +11,8 @@ use std::{
|
||||
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText};
|
||||
use ely_servo_host::{
|
||||
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest,
|
||||
PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, ScreenshotRequest,
|
||||
ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest,
|
||||
WebViewState,
|
||||
PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, ScrollRequest,
|
||||
ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewState,
|
||||
};
|
||||
|
||||
const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
|
||||
@@ -336,17 +335,6 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
|
||||
site.url
|
||||
);
|
||||
assert_rendered_frame_has_content(&host, site.url, MINIMUM_CONTENT_PIXELS)?;
|
||||
if site.url == "https://example.com" {
|
||||
let screenshot =
|
||||
host.capture_screenshot(ScreenshotRequest { webview_id: webview_id.clone() })?;
|
||||
assert_frame_has_dimensions_and_content(
|
||||
&screenshot,
|
||||
"https://example.com screenshot",
|
||||
INITIAL_WIDTH,
|
||||
INITIAL_HEIGHT,
|
||||
MINIMUM_CONTENT_PIXELS,
|
||||
);
|
||||
}
|
||||
previous_frame_hash = Some(host.last_rendered_frame()?.sample_hash());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user