feat(servo): isolate profiles with hardware sidecars
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[path = "ely_servo_sidecar/live_surface.rs"]
|
||||
mod live_surface;
|
||||
|
||||
use args::SidecarCommand;
|
||||
|
||||
fn main() -> Result<(), SidecarError> {
|
||||
match args::parse_env_command()? {
|
||||
SidecarCommand::Live(args) => live::run(args)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum SidecarError {
|
||||
#[error(transparent)]
|
||||
Args(#[from] args::SidecarArgsError),
|
||||
|
||||
#[error(transparent)]
|
||||
Live(#[from] live_protocol::LiveSidecarError),
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub(super) enum SidecarCommand {
|
||||
Live(LiveArgs),
|
||||
}
|
||||
|
||||
pub(super) struct LiveArgs {
|
||||
pub(super) profile_data_dir: PathBuf,
|
||||
pub(super) rendering_context: SidecarRenderingContext,
|
||||
pub(super) iosurface_mach_service: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(super) enum SidecarRenderingContext {
|
||||
#[default]
|
||||
Software,
|
||||
Hardware,
|
||||
}
|
||||
|
||||
#[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} path is empty")]
|
||||
EmptyPath { name: &'static str },
|
||||
|
||||
#[error("invalid rendering context: {value}")]
|
||||
InvalidRenderingContext { value: String },
|
||||
|
||||
#[error("{name} value is empty")]
|
||||
EmptyValue { name: &'static str },
|
||||
}
|
||||
|
||||
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),
|
||||
_ => 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 rendering_context = SidecarRenderingContext::Software;
|
||||
let mut iosurface_mach_service = None;
|
||||
while let Some(name) = args.next() {
|
||||
match name.as_str() {
|
||||
"--profile-data-dir" => {
|
||||
let value = next_argument(&mut args, "--profile-data-dir")?;
|
||||
profile_data_dir = Some(parse_path("--profile-data-dir", value)?);
|
||||
}
|
||||
"--rendering-context" => {
|
||||
let value = next_argument(&mut args, "--rendering-context")?;
|
||||
rendering_context = match value.as_str() {
|
||||
"software" => SidecarRenderingContext::Software,
|
||||
"hardware" => SidecarRenderingContext::Hardware,
|
||||
_ => return Err(SidecarArgsError::InvalidRenderingContext { value }),
|
||||
};
|
||||
}
|
||||
"--iosurface-mach-service" => {
|
||||
let value = next_argument(&mut args, "--iosurface-mach-service")?;
|
||||
iosurface_mach_service = Some(parse_nonempty("--iosurface-mach-service", value)?);
|
||||
}
|
||||
_ => return Err(SidecarArgsError::UnknownArgument { value: name }),
|
||||
}
|
||||
}
|
||||
|
||||
let profile_data_dir = profile_data_dir
|
||||
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?;
|
||||
Ok(LiveArgs { profile_data_dir, rendering_context, iosurface_mach_service })
|
||||
}
|
||||
|
||||
fn next_argument(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
name: &'static str,
|
||||
) -> Result<String, SidecarArgsError> {
|
||||
args.next().ok_or(SidecarArgsError::MissingArgumentValue { name })
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
fn parse_nonempty(name: &'static str, value: String) -> Result<String, SidecarArgsError> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(SidecarArgsError::EmptyValue { name });
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_live_profile_data_directory() -> Result<(), SidecarArgsError> {
|
||||
let command = parse_command([
|
||||
"ely_servo_sidecar".to_string(),
|
||||
"live".to_string(),
|
||||
"--profile-data-dir".to_string(),
|
||||
"/tmp/ely-profile".to_string(),
|
||||
])?;
|
||||
|
||||
let SidecarCommand::Live(args) = command;
|
||||
assert_eq!(args.profile_data_dir, PathBuf::from("/tmp/ely-profile"));
|
||||
assert_eq!(args.rendering_context, SidecarRenderingContext::Software);
|
||||
assert_eq!(args.iosurface_mach_service, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hardware_rendering_context_and_mach_service() -> Result<(), SidecarArgsError> {
|
||||
let command = parse_command([
|
||||
"ely_servo_sidecar".to_string(),
|
||||
"live".to_string(),
|
||||
"--profile-data-dir".to_string(),
|
||||
"/tmp/ely-profile".to_string(),
|
||||
"--rendering-context".to_string(),
|
||||
"hardware".to_string(),
|
||||
"--iosurface-mach-service".to_string(),
|
||||
"com.ely.browser.iosurface.test".to_string(),
|
||||
])?;
|
||||
|
||||
let SidecarCommand::Live(args) = command;
|
||||
assert_eq!(args.rendering_context, SidecarRenderingContext::Hardware);
|
||||
assert_eq!(args.iosurface_mach_service.as_deref(), Some("com.ely.browser.iosurface.test"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_profile_data_directory() {
|
||||
let result = parse_command(["ely_servo_sidecar".to_string(), "live".to_string()]);
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
io::{self, BufRead},
|
||||
};
|
||||
|
||||
use ely_domain::{ProfileId, TabId, UrlText};
|
||||
use ely_servo_host::{
|
||||
NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize, SoftwareServoHost,
|
||||
};
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
use super::live_surface::HardwareSurfaceTransport;
|
||||
use super::{
|
||||
args::{LiveArgs, SidecarRenderingContext},
|
||||
live_output::write_outcome,
|
||||
live_protocol::{
|
||||
LIVE_PROTOCOL_VERSION, LiveFrameReport, LiveOutcome, LiveRequest, LiveSidecarError,
|
||||
validated_frame_byte_count,
|
||||
},
|
||||
live_session::{
|
||||
LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, bind_profile,
|
||||
ensure_session,
|
||||
},
|
||||
};
|
||||
|
||||
pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> {
|
||||
let LiveArgs { profile_data_dir, rendering_context, iosurface_mach_service } = args;
|
||||
fs::create_dir_all(&profile_data_dir)?;
|
||||
let rendering_context_kind = match rendering_context {
|
||||
SidecarRenderingContext::Software => RenderingContextKind::Software,
|
||||
SidecarRenderingContext::Hardware => RenderingContextKind::Hardware,
|
||||
};
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
let mut hardware_transport = match rendering_context {
|
||||
SidecarRenderingContext::Software => None,
|
||||
SidecarRenderingContext::Hardware => {
|
||||
let service = iosurface_mach_service
|
||||
.as_deref()
|
||||
.ok_or(LiveSidecarError::IOSurfaceMachServiceRequired)?;
|
||||
Some(HardwareSurfaceTransport::connect(service)?)
|
||||
}
|
||||
};
|
||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
||||
drop(iosurface_mach_service);
|
||||
let mut host = SoftwareServoHost::new_with_config_dir_and_kind(
|
||||
ServoSurfaceSize::new(1, 1),
|
||||
Some(profile_data_dir),
|
||||
rendering_context_kind,
|
||||
)?;
|
||||
let mut sessions = HashMap::new();
|
||||
let mut active_profile = None;
|
||||
let mut handshake_complete = false;
|
||||
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;
|
||||
}
|
||||
let request = serde_json::from_str::<LiveRequest>(&line);
|
||||
let should_shutdown =
|
||||
request.as_ref().is_ok_and(|request| matches!(request, LiveRequest::Shutdown));
|
||||
let outcome = request.map_err(LiveSidecarError::from).and_then(|request| {
|
||||
handle_request(
|
||||
&mut host,
|
||||
&mut sessions,
|
||||
&mut active_profile,
|
||||
&mut handshake_complete,
|
||||
rendering_context_kind,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
hardware_transport.as_mut(),
|
||||
request,
|
||||
)
|
||||
});
|
||||
write_outcome(&mut stdout, outcome)?;
|
||||
if should_shutdown {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_request(
|
||||
host: &mut SoftwareServoHost,
|
||||
sessions: &mut HashMap<String, LiveSession>,
|
||||
active_profile: &mut Option<ProfileId>,
|
||||
handshake_complete: &mut bool,
|
||||
rendering_context_kind: RenderingContextKind,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))] hardware_transport: Option<
|
||||
&mut HardwareSurfaceTransport,
|
||||
>,
|
||||
request: LiveRequest,
|
||||
) -> Result<LiveOutcome, LiveSidecarError> {
|
||||
if !*handshake_complete
|
||||
&& !matches!(&request, LiveRequest::Handshake { .. } | LiveRequest::Shutdown)
|
||||
{
|
||||
return Err(LiveSidecarError::ProtocolHandshakeRequired);
|
||||
}
|
||||
match request {
|
||||
LiveRequest::Handshake { protocol_version } => {
|
||||
if protocol_version != LIVE_PROTOCOL_VERSION {
|
||||
return Err(LiveSidecarError::ProtocolVersionMismatch {
|
||||
expected: LIVE_PROTOCOL_VERSION,
|
||||
actual: protocol_version,
|
||||
});
|
||||
}
|
||||
*handshake_complete = true;
|
||||
Ok(LiveOutcome::empty())
|
||||
}
|
||||
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,
|
||||
pending_surface_ids,
|
||||
} => {
|
||||
validated_frame_byte_count(width, height)?;
|
||||
let tab = TabId::parse(tab_id.clone())?;
|
||||
let profile = ProfileId::parse(profile_id)?;
|
||||
bind_profile(active_profile, &profile)?;
|
||||
let url = UrlText::parse(url)?;
|
||||
let session =
|
||||
ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?;
|
||||
apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)?;
|
||||
apply_permissions(host, session, &profile, site_permissions)?;
|
||||
if session.requested_url != url.as_str() {
|
||||
let servo_current_url =
|
||||
host.snapshot(&session.webview_id)?.url().map(str::to_string);
|
||||
if servo_current_url.as_deref() == Some(url.as_str()) {
|
||||
session.requested_url = url.as_str().to_string();
|
||||
} else {
|
||||
session.clear_presented_frame();
|
||||
host.navigate(NavigationRequest {
|
||||
webview_id: session.webview_id.clone(),
|
||||
tab_id: tab,
|
||||
url: url.clone(),
|
||||
})?;
|
||||
session.requested_url = url.as_str().to_string();
|
||||
}
|
||||
}
|
||||
apply_input(
|
||||
host,
|
||||
session,
|
||||
LiveInput {
|
||||
scroll_delta_x,
|
||||
scroll_delta_y,
|
||||
scroll_point_x,
|
||||
scroll_point_y,
|
||||
click_x,
|
||||
click_y,
|
||||
hover_x,
|
||||
hover_y,
|
||||
typed_text,
|
||||
},
|
||||
)?;
|
||||
let webview_id = session.webview_id.clone();
|
||||
let outcome = poll_frame(
|
||||
host,
|
||||
session,
|
||||
rendering_context_kind,
|
||||
&ready_surface_ids,
|
||||
&pending_surface_ids,
|
||||
)?;
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
let outcome = {
|
||||
let mut outcome = outcome;
|
||||
if let Some(transport) = hardware_transport {
|
||||
transport.publish_frame(
|
||||
host,
|
||||
&tab_id,
|
||||
&webview_id,
|
||||
&ready_surface_ids,
|
||||
&pending_surface_ids,
|
||||
&mut outcome,
|
||||
)?;
|
||||
}
|
||||
outcome
|
||||
};
|
||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
||||
drop((webview_id, ready_surface_ids, pending_surface_ids));
|
||||
Ok(outcome)
|
||||
}
|
||||
LiveRequest::Poll { tab_id, ready_surface_ids, pending_surface_ids } => {
|
||||
let Some(session) = sessions.get_mut(&tab_id) else {
|
||||
return Ok(LiveOutcome::empty());
|
||||
};
|
||||
let webview_id = session.webview_id.clone();
|
||||
let outcome = poll_frame(
|
||||
host,
|
||||
session,
|
||||
rendering_context_kind,
|
||||
&ready_surface_ids,
|
||||
&pending_surface_ids,
|
||||
)?;
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
let outcome = {
|
||||
let mut outcome = outcome;
|
||||
if let Some(transport) = hardware_transport {
|
||||
transport.publish_frame(
|
||||
host,
|
||||
&tab_id,
|
||||
&webview_id,
|
||||
&ready_surface_ids,
|
||||
&pending_surface_ids,
|
||||
&mut outcome,
|
||||
)?;
|
||||
}
|
||||
outcome
|
||||
};
|
||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
||||
drop((webview_id, ready_surface_ids, pending_surface_ids));
|
||||
Ok(outcome)
|
||||
}
|
||||
LiveRequest::Close { tab_id } => {
|
||||
if let Some(session) = sessions.remove(&tab_id) {
|
||||
host.close_webview(&session.webview_id);
|
||||
}
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
if let Some(transport) = hardware_transport {
|
||||
transport.close_tab(&tab_id);
|
||||
}
|
||||
Ok(LiveOutcome::empty())
|
||||
}
|
||||
LiveRequest::Shutdown => Ok(LiveOutcome::empty()),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &mut LiveSession,
|
||||
rendering_context_kind: RenderingContextKind,
|
||||
ready_surface_ids: &[u64],
|
||||
pending_surface_ids: &[u64],
|
||||
) -> Result<LiveOutcome, LiveSidecarError> {
|
||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
||||
let _ = (ready_surface_ids, pending_surface_ids);
|
||||
match rendering_context_kind {
|
||||
RenderingContextKind::Software => poll_software_frame(host, session),
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
RenderingContextKind::Hardware => {
|
||||
poll_hardware_frame(host, session, ready_surface_ids, pending_surface_ids)
|
||||
}
|
||||
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
|
||||
RenderingContextKind::Hardware => poll_software_frame(host, session),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_software_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &mut LiveSession,
|
||||
) -> Result<LiveOutcome, LiveSidecarError> {
|
||||
host.tick();
|
||||
let snapshot = host.snapshot(&session.webview_id)?;
|
||||
let has_pending_frame = snapshot.has_pending_frame();
|
||||
if !has_pending_frame {
|
||||
if snapshot.has_pending_metadata()
|
||||
&& let Some(frame) = session.last_frame.clone()
|
||||
{
|
||||
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
|
||||
let report =
|
||||
LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), false);
|
||||
return Ok(LiveOutcome::frame(report, frame));
|
||||
}
|
||||
return Ok(LiveOutcome::empty());
|
||||
}
|
||||
|
||||
host.paint_with_readback(&session.webview_id)?;
|
||||
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
|
||||
let frame = host.last_rendered_frame()?;
|
||||
session.last_frame = Some(frame.clone());
|
||||
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), true);
|
||||
Ok(LiveOutcome::frame(report, frame))
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
fn poll_hardware_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &mut LiveSession,
|
||||
ready_surface_ids: &[u64],
|
||||
pending_surface_ids: &[u64],
|
||||
) -> Result<LiveOutcome, LiveSidecarError> {
|
||||
host.acknowledge_iosurfaces(&session.webview_id, ready_surface_ids)?;
|
||||
let (last_surface_became_ready, last_surface_is_missing) = session
|
||||
.last_surface
|
||||
.map(|identity| {
|
||||
let was_ready = session.last_surface_ready;
|
||||
let ready = ready_surface_ids.contains(&identity.surface_id);
|
||||
let pending = pending_surface_ids.contains(&identity.surface_id);
|
||||
session.last_surface_ready = ready;
|
||||
(ready && !was_ready, !ready && !pending)
|
||||
})
|
||||
.unwrap_or((false, false));
|
||||
host.tick();
|
||||
let snapshot = host.snapshot(&session.webview_id)?;
|
||||
match hardware_poll_action(
|
||||
snapshot.has_pending_frame(),
|
||||
snapshot.has_pending_metadata(),
|
||||
last_surface_became_ready,
|
||||
last_surface_is_missing,
|
||||
session.last_surface.is_some(),
|
||||
session.last_surface_ready,
|
||||
) {
|
||||
HardwarePollAction::ReplaySurface => {
|
||||
let identity = session.last_surface.ok_or_else(|| {
|
||||
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable {
|
||||
id: session.webview_id.clone(),
|
||||
}
|
||||
})?;
|
||||
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
|
||||
let report = LiveFrameReport::from_surface(
|
||||
&snapshot,
|
||||
identity.width,
|
||||
identity.height,
|
||||
session.device_pixel_ratio(),
|
||||
false,
|
||||
);
|
||||
return Ok(LiveOutcome::surface(report));
|
||||
}
|
||||
HardwarePollAction::Empty => return Ok(LiveOutcome::empty()),
|
||||
HardwarePollAction::PaintFrame => {}
|
||||
}
|
||||
|
||||
host.paint_without_readback(&session.webview_id)?;
|
||||
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
|
||||
let identity = host.peek_iosurface_identity(&session.webview_id)?.ok_or_else(|| {
|
||||
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable {
|
||||
id: session.webview_id.clone(),
|
||||
}
|
||||
})?;
|
||||
session.last_surface = Some(identity);
|
||||
session.last_surface_ready = ready_surface_ids.contains(&identity.surface_id);
|
||||
let report = LiveFrameReport::from_surface(
|
||||
&snapshot,
|
||||
identity.width,
|
||||
identity.height,
|
||||
session.device_pixel_ratio(),
|
||||
true,
|
||||
);
|
||||
Ok(LiveOutcome::surface(report))
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum HardwarePollAction {
|
||||
ReplaySurface,
|
||||
PaintFrame,
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
fn hardware_poll_action(
|
||||
has_pending_frame: bool,
|
||||
has_pending_metadata: bool,
|
||||
last_surface_became_ready: bool,
|
||||
last_surface_is_missing: bool,
|
||||
has_last_surface: bool,
|
||||
last_surface_ready: bool,
|
||||
) -> HardwarePollAction {
|
||||
if has_last_surface && (last_surface_became_ready || last_surface_is_missing) {
|
||||
HardwarePollAction::ReplaySurface
|
||||
} else if has_last_surface && !last_surface_ready {
|
||||
HardwarePollAction::Empty
|
||||
} else if has_last_surface && !has_pending_frame && has_pending_metadata {
|
||||
HardwarePollAction::ReplaySurface
|
||||
} else if has_pending_frame {
|
||||
HardwarePollAction::PaintFrame
|
||||
} else {
|
||||
HardwarePollAction::Empty
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "hardware-render", target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::{HardwarePollAction, hardware_poll_action};
|
||||
|
||||
#[test]
|
||||
fn newly_ready_surface_replays_before_pending_frame() {
|
||||
assert_eq!(
|
||||
hardware_poll_action(true, false, true, false, true, true),
|
||||
HardwarePollAction::ReplaySurface
|
||||
);
|
||||
assert_eq!(
|
||||
hardware_poll_action(true, false, false, false, true, true),
|
||||
HardwarePollAction::PaintFrame
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn awaiting_ready_surface_backpressures_pending_frame() {
|
||||
assert_eq!(
|
||||
hardware_poll_action(true, true, false, false, true, false),
|
||||
HardwarePollAction::Empty
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_surface_replays_before_first_ready() {
|
||||
assert_eq!(
|
||||
hardware_poll_action(true, false, false, true, true, false),
|
||||
HardwarePollAction::ReplaySurface
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use std::io::Write;
|
||||
|
||||
use super::live_protocol::{LiveOutcome, LiveSidecarError, validated_frame_byte_count};
|
||||
|
||||
pub(super) fn write_outcome(
|
||||
stdout: &mut impl Write,
|
||||
outcome: Result<LiveOutcome, LiveSidecarError>,
|
||||
) -> Result<(), LiveSidecarError> {
|
||||
let mut outcome = match outcome {
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => LiveOutcome::error(error.to_string()),
|
||||
};
|
||||
if let Some(frame) = outcome.frame.as_ref()
|
||||
&& let Err(error) = validate_frame(frame.width(), frame.height(), frame.rgba_bytes().len())
|
||||
{
|
||||
outcome = LiveOutcome::error(error.to_string());
|
||||
}
|
||||
|
||||
serde_json::to_writer(&mut *stdout, &outcome.response)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
if let Some(frame) = outcome.frame.as_ref() {
|
||||
stdout.write_all(frame.rgba_bytes())?;
|
||||
}
|
||||
stdout.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_frame(width: u32, height: u32, actual: usize) -> Result<(), LiveSidecarError> {
|
||||
let expected = validated_frame_byte_count(width, height)?;
|
||||
if expected != actual {
|
||||
return Err(LiveSidecarError::FrameByteCountMismatch { expected, actual });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mismatched_frame_becomes_header_only_error() -> Result<(), LiveSidecarError> {
|
||||
let frame = ely_servo_host::RenderedFrame::from_rgba_bytes(2, 2, vec![0; 4]);
|
||||
let snapshot = ely_servo_host::WebViewSnapshot::new(
|
||||
ely_domain::WebViewId::new(),
|
||||
ely_domain::TabId::new(),
|
||||
ely_domain::ProfileId::new(),
|
||||
ely_servo_host::WebViewState::Complete,
|
||||
None,
|
||||
None,
|
||||
ely_servo_host::WebViewSnapshotPending::new(false, false),
|
||||
);
|
||||
let report =
|
||||
super::super::live_protocol::LiveFrameReport::new(&snapshot, &frame, 1.0, true);
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_outcome(&mut output, Ok(LiveOutcome::frame(report, frame)))?;
|
||||
|
||||
assert!(output.ends_with(b"\n"));
|
||||
let response: serde_json::Value = serde_json::from_slice(&output)?;
|
||||
assert!(response["error"].as_str().is_some());
|
||||
assert!(response["frame"].is_null());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
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;
|
||||
|
||||
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2;
|
||||
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
|
||||
pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(super) enum LiveRequest {
|
||||
Handshake {
|
||||
protocol_version: u32,
|
||||
},
|
||||
Ensure {
|
||||
tab_id: String,
|
||||
profile_id: String,
|
||||
url: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
#[serde(default = "default_zoom_percent")]
|
||||
page_zoom_percent: u16,
|
||||
#[serde(default = "default_device_pixel_ratio")]
|
||||
device_pixel_ratio: f32,
|
||||
#[serde(default)]
|
||||
scroll_delta_x: i32,
|
||||
#[serde(default)]
|
||||
scroll_delta_y: i32,
|
||||
#[serde(default)]
|
||||
scroll_point_x: Option<u32>,
|
||||
#[serde(default)]
|
||||
scroll_point_y: Option<u32>,
|
||||
#[serde(default)]
|
||||
click_x: Option<u32>,
|
||||
#[serde(default)]
|
||||
click_y: Option<u32>,
|
||||
#[serde(default)]
|
||||
hover_x: Option<u32>,
|
||||
#[serde(default)]
|
||||
hover_y: Option<u32>,
|
||||
#[serde(default)]
|
||||
typed_text: Option<String>,
|
||||
#[serde(default)]
|
||||
site_permissions: Vec<LiveSitePermission>,
|
||||
#[serde(default)]
|
||||
ready_surface_ids: Vec<u64>,
|
||||
#[serde(default)]
|
||||
pending_surface_ids: Vec<u64>,
|
||||
},
|
||||
Poll {
|
||||
tab_id: String,
|
||||
#[serde(default)]
|
||||
ready_surface_ids: Vec<u64>,
|
||||
#[serde(default)]
|
||||
pending_surface_ids: Vec<u64>,
|
||||
},
|
||||
Close {
|
||||
tab_id: String,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
const fn default_zoom_percent() -> u16 {
|
||||
ely_domain::DEFAULT_ZOOM_PERCENT
|
||||
}
|
||||
|
||||
const fn default_device_pixel_ratio() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct LiveSitePermission {
|
||||
pub(super) origin: String,
|
||||
pub(super) feature: String,
|
||||
pub(super) decision: String,
|
||||
}
|
||||
|
||||
pub(super) struct LiveOutcome {
|
||||
pub(super) response: LiveResponse,
|
||||
pub(super) frame: Option<RenderedFrame>,
|
||||
}
|
||||
|
||||
impl LiveOutcome {
|
||||
pub(super) fn empty() -> Self {
|
||||
Self { response: LiveResponse::empty(), frame: None }
|
||||
}
|
||||
|
||||
pub(super) fn error(message: String) -> Self {
|
||||
Self { response: LiveResponse::error(message), frame: None }
|
||||
}
|
||||
|
||||
pub(super) fn frame(report: LiveFrameReport, frame: RenderedFrame) -> Self {
|
||||
Self { response: LiveResponse::frame(report), frame: Some(frame) }
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
pub(super) fn surface(report: LiveFrameReport) -> Self {
|
||||
Self { response: LiveResponse::frame(report), frame: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct LiveResponse {
|
||||
pub(super) protocol_version: u32,
|
||||
pub(super) error: Option<String>,
|
||||
pub(super) frame: Option<LiveFrameReport>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) surface_handle: Option<IOSurfaceHandle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) current_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl LiveResponse {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
protocol_version: LIVE_PROTOCOL_VERSION,
|
||||
error: None,
|
||||
frame: None,
|
||||
surface_handle: None,
|
||||
current_surface_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn error(message: String) -> Self {
|
||||
Self {
|
||||
protocol_version: LIVE_PROTOCOL_VERSION,
|
||||
error: Some(message),
|
||||
frame: None,
|
||||
surface_handle: None,
|
||||
current_surface_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(frame: LiveFrameReport) -> Self {
|
||||
Self {
|
||||
protocol_version: LIVE_PROTOCOL_VERSION,
|
||||
error: None,
|
||||
frame: Some(frame),
|
||||
surface_handle: None,
|
||||
current_surface_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct LiveFrameReport {
|
||||
pub(super) loaded_url: Option<String>,
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) state: &'static str,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
pub(super) device_pixel_ratio: f32,
|
||||
pub(super) css_viewport_width: u32,
|
||||
pub(super) css_viewport_height: u32,
|
||||
pub(super) rgba_byte_count: usize,
|
||||
pub(super) pixels_changed: bool,
|
||||
pub(super) non_white_pixel_count: u64,
|
||||
pub(super) content_pixel_count: u64,
|
||||
pub(super) sample_hash: u64,
|
||||
}
|
||||
|
||||
impl LiveFrameReport {
|
||||
pub(super) fn new(
|
||||
snapshot: &WebViewSnapshot,
|
||||
frame: &RenderedFrame,
|
||||
device_pixel_ratio: f32,
|
||||
pixels_changed: bool,
|
||||
) -> Self {
|
||||
let device_pixel_ratio = normalized_device_pixel_ratio(device_pixel_ratio);
|
||||
let css_viewport_width = css_dimension(frame.width(), device_pixel_ratio);
|
||||
let css_viewport_height = css_dimension(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(),
|
||||
pixels_changed,
|
||||
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(super) fn from_surface(
|
||||
snapshot: &WebViewSnapshot,
|
||||
width: u32,
|
||||
height: u32,
|
||||
device_pixel_ratio: f32,
|
||||
pixels_changed: bool,
|
||||
) -> Self {
|
||||
let device_pixel_ratio = normalized_device_pixel_ratio(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_dimension(width, device_pixel_ratio),
|
||||
css_viewport_height: css_dimension(height, device_pixel_ratio),
|
||||
rgba_byte_count: 0,
|
||||
pixels_changed,
|
||||
non_white_pixel_count: 0,
|
||||
content_pixel_count: 0,
|
||||
sample_hash: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_device_pixel_ratio(value: f32) -> f32 {
|
||||
if value.is_finite() && value > 0.0 { value.clamp(0.5, 5.0) } else { 1.0 }
|
||||
}
|
||||
|
||||
fn css_dimension(value: u32, device_pixel_ratio: f32) -> u32 {
|
||||
((value as f32) / device_pixel_ratio).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 protocol handshake is required before sidecar requests")]
|
||||
ProtocolHandshakeRequired,
|
||||
|
||||
#[error("live protocol mismatch: expected {expected}, received {actual}")]
|
||||
ProtocolVersionMismatch { expected: u32, actual: u32 },
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[error("hardware rendering requires --iosurface-mach-service")]
|
||||
IOSurfaceMachServiceRequired,
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[error(
|
||||
"hardware surface {surface_id} size {surface_width}x{surface_height} does not match frame report {frame_width}x{frame_height}"
|
||||
)]
|
||||
HardwareSurfaceReportMismatch {
|
||||
surface_id: u64,
|
||||
surface_width: u32,
|
||||
surface_height: u32,
|
||||
frame_width: u32,
|
||||
frame_height: u32,
|
||||
},
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
#[error("hardware IOSurface handle does not match the presented surface")]
|
||||
HardwareSurfaceHandleMismatch,
|
||||
|
||||
#[error("sidecar process is already bound to profile {expected}; received {actual}")]
|
||||
ProfileMismatch { expected: String, actual: String },
|
||||
|
||||
#[error("{input} requires both x and y coordinates")]
|
||||
IncompletePoint { input: &'static str },
|
||||
|
||||
#[error("frame dimensions overflow the protocol byte count: {width}x{height}")]
|
||||
FrameDimensionsOverflow { width: u32, height: u32 },
|
||||
|
||||
#[error("frame dimensions {width}x{height} exceed the {max_dimension}px dimension limit")]
|
||||
InvalidFrameDimensions { width: u32, height: u32, max_dimension: u32 },
|
||||
|
||||
#[error("frame requires {bytes} bytes; the protocol limit is {limit}")]
|
||||
FrameByteLimitExceeded { bytes: u64, limit: usize },
|
||||
|
||||
#[error("frame byte count mismatch: expected {expected}, received {actual}")]
|
||||
FrameByteCountMismatch { expected: usize, actual: usize },
|
||||
|
||||
#[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),
|
||||
}
|
||||
|
||||
pub(super) fn validated_frame_byte_count(
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<usize, LiveSidecarError> {
|
||||
if width == 0 || height == 0 || width > MAX_FRAME_DIMENSION || height > MAX_FRAME_DIMENSION {
|
||||
return Err(LiveSidecarError::InvalidFrameDimensions {
|
||||
width,
|
||||
height,
|
||||
max_dimension: MAX_FRAME_DIMENSION,
|
||||
});
|
||||
}
|
||||
let bytes = u64::from(width)
|
||||
.checked_mul(u64::from(height))
|
||||
.and_then(|pixels| pixels.checked_mul(4))
|
||||
.ok_or(LiveSidecarError::FrameDimensionsOverflow { width, height })?;
|
||||
if bytes > MAX_FRAME_BYTE_COUNT as u64 {
|
||||
return Err(LiveSidecarError::FrameByteLimitExceeded {
|
||||
bytes,
|
||||
limit: MAX_FRAME_BYTE_COUNT,
|
||||
});
|
||||
}
|
||||
Ok(bytes as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ensure_defaults_optional_input_fields() -> Result<(), serde_json::Error> {
|
||||
let request = serde_json::from_str::<LiveRequest>(
|
||||
r#"{"type":"ensure","tab_id":"tab","profile_id":"profile","url":"https://example.com","width":800,"height":600}"#,
|
||||
)?;
|
||||
|
||||
assert!(matches!(
|
||||
request,
|
||||
LiveRequest::Ensure {
|
||||
page_zoom_percent: 100,
|
||||
device_pixel_ratio: 1.0,
|
||||
scroll_delta_x: 0,
|
||||
scroll_delta_y: 0,
|
||||
ready_surface_ids,
|
||||
pending_surface_ids,
|
||||
..
|
||||
} if ready_surface_ids.is_empty() && pending_surface_ids.is_empty()
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_deserializes_protocol_version() -> Result<(), serde_json::Error> {
|
||||
let request =
|
||||
serde_json::from_str::<LiveRequest>(r#"{"type":"handshake","protocol_version":2}"#)?;
|
||||
|
||||
assert!(matches!(request, LiveRequest::Handshake { protocol_version: 2 }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_layout_enforces_dimension_and_byte_limits() {
|
||||
assert!(matches!(
|
||||
validated_frame_byte_count(MAX_FRAME_DIMENSION + 1, 1),
|
||||
Err(LiveSidecarError::InvalidFrameDimensions { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
validated_frame_byte_count(MAX_FRAME_DIMENSION, MAX_FRAME_DIMENSION),
|
||||
Err(LiveSidecarError::FrameByteLimitExceeded { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_deserializes_from_wire() -> Result<(), serde_json::Error> {
|
||||
let request = serde_json::from_str::<LiveRequest>(r#"{"type":"shutdown"}"#)?;
|
||||
|
||||
assert!(matches!(request, LiveRequest::Shutdown));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use std::collections::{HashMap, hash_map::Entry};
|
||||
|
||||
use ely_domain::{ProfileId, TabId, validate_zoom_percent};
|
||||
use ely_servo_host::{
|
||||
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, PageZoomRequest,
|
||||
PermissionDecision, PermissionRequest, RenderedFrame, ResizeRequest, ScrollRequest, ServoHost,
|
||||
ServoSurfaceSize, SoftwareServoHost,
|
||||
};
|
||||
|
||||
use super::live_protocol::{LiveSidecarError, LiveSitePermission};
|
||||
|
||||
pub(super) struct LiveSession {
|
||||
pub(super) webview_id: ely_domain::WebViewId,
|
||||
pub(super) requested_url: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
page_zoom_percent: u16,
|
||||
hidpi_scale_milli: u32,
|
||||
pub(super) last_frame: Option<RenderedFrame>,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
pub(super) last_surface: Option<ely_servo_host::IOSurfaceIdentity>,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
pub(super) last_surface_ready: bool,
|
||||
}
|
||||
|
||||
impl LiveSession {
|
||||
fn new(webview_id: ely_domain::WebViewId) -> Self {
|
||||
Self {
|
||||
webview_id,
|
||||
requested_url: String::new(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
page_zoom_percent: ely_domain::DEFAULT_ZOOM_PERCENT,
|
||||
hidpi_scale_milli: 0,
|
||||
last_frame: None,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
last_surface: None,
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
last_surface_ready: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn device_pixel_ratio(&self) -> f32 {
|
||||
self.hidpi_scale_milli as f32 / 1_000.0
|
||||
}
|
||||
|
||||
pub(super) fn clear_presented_frame(&mut self) {
|
||||
self.last_frame = None;
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
{
|
||||
self.last_surface = None;
|
||||
self.last_surface_ready = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bind_profile(
|
||||
active_profile: &mut Option<ProfileId>,
|
||||
profile: &ProfileId,
|
||||
) -> Result<(), LiveSidecarError> {
|
||||
match active_profile {
|
||||
Some(expected) if expected != profile => Err(LiveSidecarError::ProfileMismatch {
|
||||
expected: expected.to_string(),
|
||||
actual: profile.to_string(),
|
||||
}),
|
||||
Some(_) => Ok(()),
|
||||
None => {
|
||||
*active_profile = Some(profile.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
match sessions.entry(key) {
|
||||
Entry::Occupied(entry) => Ok(entry.into_mut()),
|
||||
Entry::Vacant(entry) => {
|
||||
let webview_id = host.create_webview_with_size(
|
||||
tab_id.clone(),
|
||||
profile_id.clone(),
|
||||
ServoSurfaceSize::new(width, height),
|
||||
)?;
|
||||
Ok(entry.insert(LiveSession::new(webview_id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_layout(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &mut LiveSession,
|
||||
width: u32,
|
||||
height: u32,
|
||||
page_zoom_percent: u16,
|
||||
device_pixel_ratio: f32,
|
||||
) -> Result<(), LiveSidecarError> {
|
||||
let page_zoom_percent = validate_zoom_percent(page_zoom_percent)?;
|
||||
let hidpi_scale_milli = encode_hidpi_scale_milli(device_pixel_ratio);
|
||||
if session.hidpi_scale_milli != hidpi_scale_milli {
|
||||
session.clear_presented_frame();
|
||||
host.set_hidpi_scale(HidpiScaleRequest {
|
||||
webview_id: session.webview_id.clone(),
|
||||
scale_factor: hidpi_scale_milli as f32 / 1_000.0,
|
||||
})?;
|
||||
session.hidpi_scale_milli = hidpi_scale_milli;
|
||||
}
|
||||
if session.width != width || session.height != height {
|
||||
session.clear_presented_frame();
|
||||
host.resize(ResizeRequest { webview_id: session.webview_id.clone(), width, height })?;
|
||||
session.width = width;
|
||||
session.height = height;
|
||||
}
|
||||
if session.page_zoom_percent != page_zoom_percent {
|
||||
session.clear_presented_frame();
|
||||
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;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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)?,
|
||||
},
|
||||
PermissionDecision::from(ely_domain::SitePermissionDecision::parse(
|
||||
&permission.decision,
|
||||
)?),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
pub(super) fn apply_input(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &LiveSession,
|
||||
input: LiveInput,
|
||||
) -> Result<(), LiveSidecarError> {
|
||||
if input.scroll_delta_x != 0 || input.scroll_delta_y != 0 {
|
||||
let (point_x, point_y) =
|
||||
paired_point("scroll input", input.scroll_point_x, input.scroll_point_y)?;
|
||||
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,
|
||||
})?;
|
||||
}
|
||||
if input.hover_x.is_some() || input.hover_y.is_some() {
|
||||
let (x, y) = paired_point("hover input", input.hover_x, input.hover_y)?;
|
||||
host.hover(MouseHoverRequest { webview_id: session.webview_id.clone(), x, y })?;
|
||||
}
|
||||
if input.click_x.is_some() || input.click_y.is_some() {
|
||||
let (x, y) = paired_point("click input", input.click_x, input.click_y)?;
|
||||
host.click(MouseClickRequest { webview_id: session.webview_id.clone(), x, y })?;
|
||||
}
|
||||
if let Some(text) = input.typed_text {
|
||||
host.type_text(KeyboardTextRequest { webview_id: session.webview_id.clone(), text })?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn paired_point(
|
||||
input: &'static str,
|
||||
x: Option<u32>,
|
||||
y: Option<u32>,
|
||||
) -> Result<(u32, u32), LiveSidecarError> {
|
||||
match (x, y) {
|
||||
(Some(x), Some(y)) => Ok((x, y)),
|
||||
_ => Err(LiveSidecarError::IncompletePoint { input }),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_hidpi_scale_milli(scale: f32) -> u32 {
|
||||
if scale.is_finite() && scale > 0.0 {
|
||||
(scale * 1_000.0).round().clamp(500.0, 5_000.0) as u32
|
||||
} else {
|
||||
1_000
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn process_binds_to_first_profile() -> Result<(), LiveSidecarError> {
|
||||
let first = ProfileId::new();
|
||||
let second = ProfileId::new();
|
||||
let mut active = None;
|
||||
|
||||
bind_profile(&mut active, &first)?;
|
||||
assert!(matches!(
|
||||
bind_profile(&mut active, &second),
|
||||
Err(LiveSidecarError::ProfileMismatch { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use ely_servo_host::{IOSurfaceIdentity, SoftwareServoHost};
|
||||
|
||||
use super::{
|
||||
iosurface_mach::IOSurfaceMachSender,
|
||||
live_protocol::{LiveOutcome, LiveSidecarError},
|
||||
};
|
||||
|
||||
pub(super) struct HardwareSurfaceTransport {
|
||||
sender: IOSurfaceMachSender,
|
||||
publications: SurfacePublications,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SurfacePublications {
|
||||
by_tab: HashMap<String, HashMap<IOSurfaceIdentity, PublicationState>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum PublicationState {
|
||||
AwaitingReady,
|
||||
Ready,
|
||||
}
|
||||
|
||||
impl HardwareSurfaceTransport {
|
||||
pub(super) fn connect(service_name: &str) -> Result<Self, LiveSidecarError> {
|
||||
Ok(Self {
|
||||
sender: IOSurfaceMachSender::connect(service_name)?,
|
||||
publications: SurfacePublications::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn publish_frame(
|
||||
&mut self,
|
||||
host: &SoftwareServoHost,
|
||||
tab_id: &str,
|
||||
webview_id: &ely_domain::WebViewId,
|
||||
ready_surface_ids: &[u64],
|
||||
pending_surface_ids: &[u64],
|
||||
outcome: &mut LiveOutcome,
|
||||
) -> Result<(), LiveSidecarError> {
|
||||
self.publications.sync_client_state(tab_id, ready_surface_ids, pending_surface_ids);
|
||||
let Some(report) = outcome.response.frame.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let identity = host.peek_iosurface_identity(webview_id)?.ok_or_else(|| {
|
||||
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable { id: webview_id.clone() }
|
||||
})?;
|
||||
validate_report(identity, report.width, report.height)?;
|
||||
outcome.response.current_surface_id = Some(identity.surface_id);
|
||||
if self.publications.contains(tab_id, identity) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let handle = host.current_iosurface_handle(webview_id)?.ok_or_else(|| {
|
||||
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable { id: webview_id.clone() }
|
||||
})?;
|
||||
if IOSurfaceIdentity::from_handle(handle) != identity {
|
||||
return Err(LiveSidecarError::HardwareSurfaceHandleMismatch);
|
||||
}
|
||||
self.sender.send_surface_port(handle.surface_id, handle.mach_port_name)?;
|
||||
outcome.response.surface_handle = Some(handle);
|
||||
self.publications.insert(tab_id, identity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn close_tab(&mut self, tab_id: &str) {
|
||||
self.publications.remove(tab_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl SurfacePublications {
|
||||
fn sync_client_state(
|
||||
&mut self,
|
||||
tab_id: &str,
|
||||
ready_surface_ids: &[u64],
|
||||
pending_surface_ids: &[u64],
|
||||
) {
|
||||
let ready: HashSet<u64> = ready_surface_ids.iter().copied().collect();
|
||||
let pending: HashSet<u64> = pending_surface_ids.iter().copied().collect();
|
||||
let Some(surfaces) = self.by_tab.get_mut(tab_id) else {
|
||||
return;
|
||||
};
|
||||
surfaces.retain(|identity, state| match state {
|
||||
PublicationState::AwaitingReady => {
|
||||
if ready.contains(&identity.surface_id) {
|
||||
*state = PublicationState::Ready;
|
||||
}
|
||||
ready.contains(&identity.surface_id) || pending.contains(&identity.surface_id)
|
||||
}
|
||||
PublicationState::Ready => ready.contains(&identity.surface_id),
|
||||
});
|
||||
}
|
||||
|
||||
fn contains(&self, tab_id: &str, identity: IOSurfaceIdentity) -> bool {
|
||||
self.by_tab.get(tab_id).is_some_and(|surfaces| surfaces.contains_key(&identity))
|
||||
}
|
||||
|
||||
fn insert(&mut self, tab_id: &str, identity: IOSurfaceIdentity) {
|
||||
self.by_tab
|
||||
.entry(tab_id.to_string())
|
||||
.or_default()
|
||||
.insert(identity, PublicationState::AwaitingReady);
|
||||
}
|
||||
|
||||
fn remove(&mut self, tab_id: &str) {
|
||||
self.by_tab.remove(tab_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_report(
|
||||
identity: IOSurfaceIdentity,
|
||||
frame_width: u32,
|
||||
frame_height: u32,
|
||||
) -> Result<(), LiveSidecarError> {
|
||||
if identity.width == frame_width && identity.height == frame_height {
|
||||
return Ok(());
|
||||
}
|
||||
Err(LiveSidecarError::HardwareSurfaceReportMismatch {
|
||||
surface_id: identity.surface_id,
|
||||
surface_width: identity.width,
|
||||
surface_height: identity.height,
|
||||
frame_width,
|
||||
frame_height,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn surface_publication_tracks_pending_ready_and_evicted_states() {
|
||||
let identity = IOSurfaceIdentity { surface_id: 7, width: 800, height: 600 };
|
||||
let mut publications = SurfacePublications::default();
|
||||
publications.insert("tab", identity);
|
||||
|
||||
publications.sync_client_state("tab", &[], &[7]);
|
||||
assert!(publications.contains("tab", identity));
|
||||
publications.sync_client_state("tab", &[7], &[]);
|
||||
assert!(publications.contains("tab", identity));
|
||||
publications.sync_client_state("tab", &[], &[]);
|
||||
assert!(!publications.contains("tab", identity));
|
||||
|
||||
publications.insert("tab", identity);
|
||||
publications.sync_client_state("tab", &[], &[7]);
|
||||
assert!(publications.contains("tab", identity));
|
||||
publications.sync_client_state("tab", &[7], &[]);
|
||||
assert!(publications.contains("tab", identity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seventeenth_surface_missing_before_ready_is_republished() {
|
||||
let mut publications = SurfacePublications::default();
|
||||
let identities = (1..=17)
|
||||
.map(|surface_id| IOSurfaceIdentity { surface_id, width: 64, height: 48 })
|
||||
.collect::<Vec<_>>();
|
||||
for identity in &identities {
|
||||
publications.insert("tab", *identity);
|
||||
}
|
||||
|
||||
publications.sync_client_state("tab", &[], &(1..=17).collect::<Vec<_>>());
|
||||
publications.sync_client_state("tab", &[], &(2..=17).collect::<Vec<_>>());
|
||||
|
||||
assert!(!publications.contains("tab", identities[0]));
|
||||
assert!(publications.contains("tab", identities[16]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user