Render Servo sidecar frames in shell

This commit is contained in:
2026-05-08 12:43:38 -04:00
parent fcdc2de076
commit 2fb6b15e2d
10 changed files with 656 additions and 30 deletions
+1
View File
@@ -3,6 +3,7 @@ pub mod download_files;
pub mod plugin_package_store;
pub mod plugin_packages;
pub mod plugin_signatures;
pub mod servo_sidecar;
#[cfg(test)]
mod plugin_package_test_support;
@@ -0,0 +1,253 @@
use std::{
env, fs, io,
path::{Path, PathBuf},
process::Command,
time::{SystemTime, SystemTimeError, UNIX_EPOCH},
};
use ely_domain::UrlText;
use serde::Deserialize;
use thiserror::Error;
#[derive(Clone, Debug)]
pub struct ServoSidecarClient {
binary_path: PathBuf,
}
impl ServoSidecarClient {
pub fn new() -> Result<Self, ServoSidecarError> {
Ok(Self { binary_path: default_sidecar_path()? })
}
pub fn snapshot(
&self,
request: SidecarSnapshotRequest,
) -> Result<SidecarSnapshot, ServoSidecarError> {
if !self.binary_path.is_file() {
return Err(ServoSidecarError::SidecarBinaryUnavailable {
path: self.binary_path.clone(),
});
}
let rgba_path = temporary_rgba_path()?;
let output = self.run_snapshot_command(&request, &rgba_path)?;
if !output.status.success() {
remove_temporary_file(&rgba_path)?;
return Err(ServoSidecarError::SidecarFailed {
status: output.status.to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
let snapshot = read_sidecar_snapshot(&output.stdout, &rgba_path);
let cleanup = remove_temporary_file(&rgba_path);
match (snapshot, cleanup) {
(Ok(snapshot), Ok(())) => Ok(snapshot),
(Err(error), Ok(())) => Err(error),
(Ok(_), Err(error)) | (Err(_), Err(error)) => Err(error),
}
}
fn run_snapshot_command(
&self,
request: &SidecarSnapshotRequest,
rgba_path: &Path,
) -> Result<std::process::Output, ServoSidecarError> {
Command::new(&self.binary_path)
.arg("snapshot")
.arg("--url")
.arg(request.url.as_str())
.arg("--rgba-out")
.arg(rgba_path)
.arg("--width")
.arg(request.width.to_string())
.arg("--height")
.arg(request.height.to_string())
.output()
.map_err(ServoSidecarError::Command)
}
}
#[derive(Clone, Debug)]
pub struct SidecarSnapshotRequest {
url: UrlText,
width: u32,
height: u32,
}
impl SidecarSnapshotRequest {
#[must_use]
pub fn new(url: UrlText, width: u32, height: u32) -> Self {
Self { url, width, height }
}
}
#[derive(Clone, Debug)]
pub struct SidecarSnapshot {
loaded_url: Option<String>,
title: Option<String>,
width: u32,
height: u32,
rgba_bytes: Vec<u8>,
}
impl SidecarSnapshot {
fn from_report(report: SidecarReport, rgba_bytes: Vec<u8>) -> Result<Self, ServoSidecarError> {
let expected_byte_count = expected_rgba_byte_count(report.width, report.height)?;
if report.state != "complete" {
return Err(ServoSidecarError::IncompleteRender { state: report.state });
}
if report.rgba_byte_count != expected_byte_count || rgba_bytes.len() != expected_byte_count
{
return Err(ServoSidecarError::RgbaByteCountMismatch {
expected: expected_byte_count,
reported: report.rgba_byte_count,
actual: rgba_bytes.len(),
});
}
if report.non_white_pixel_count == 0 {
return Err(ServoSidecarError::BlankRenderedFrame {
requested_url: report.requested_url,
});
}
Ok(Self {
loaded_url: report.loaded_url,
title: report.title,
width: report.width,
height: report.height,
rgba_bytes,
})
}
#[must_use]
pub fn loaded_url(&self) -> Option<&str> {
self.loaded_url.as_deref()
}
#[must_use]
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
#[must_use]
pub fn width(&self) -> u32 {
self.width
}
#[must_use]
pub fn height(&self) -> u32 {
self.height
}
#[must_use]
pub fn into_rgba_bytes(self) -> Vec<u8> {
self.rgba_bytes
}
}
#[derive(Debug, Error)]
pub enum ServoSidecarError {
#[error("current executable path is unavailable: {0}")]
CurrentExecutable(#[source] io::Error),
#[error("current executable directory is unavailable for {path}")]
CurrentExecutableDirectoryUnavailable { path: PathBuf },
#[error("servo sidecar binary is unavailable at {path}")]
SidecarBinaryUnavailable { path: PathBuf },
#[error("temporary frame directory is unavailable: {0}")]
TempDirectory(#[source] io::Error),
#[error("system clock is before UNIX epoch")]
SystemClock(#[from] SystemTimeError),
#[error("failed to run servo sidecar: {0}")]
Command(#[source] io::Error),
#[error("servo sidecar exited with {status}: {stderr}")]
SidecarFailed { status: String, stderr: String },
#[error("failed to parse servo sidecar report: {0}")]
Report(#[from] serde_json::Error),
#[error("failed to read servo frame file: {0}")]
FrameRead(#[source] io::Error),
#[error("failed to remove servo frame file: {0}")]
FrameCleanup(#[source] io::Error),
#[error("servo sidecar returned incomplete render state: {state}")]
IncompleteRender { state: String },
#[error(
"servo frame byte count mismatch: expected {expected}, reported {reported}, actual {actual}"
)]
RgbaByteCountMismatch { expected: usize, reported: usize, actual: usize },
#[error("servo frame dimensions overflow byte count: {width}x{height}")]
RgbaByteCountOverflow { width: u32, height: u32 },
#[error("servo rendered a blank frame for {requested_url}")]
BlankRenderedFrame { requested_url: String },
}
#[derive(Deserialize)]
struct SidecarReport {
requested_url: String,
loaded_url: Option<String>,
title: Option<String>,
state: String,
width: u32,
height: u32,
rgba_byte_count: usize,
non_white_pixel_count: u64,
}
fn default_sidecar_path() -> Result<PathBuf, ServoSidecarError> {
let current_exe = env::current_exe().map_err(ServoSidecarError::CurrentExecutable)?;
let exe_dir = current_exe.parent().ok_or_else(|| {
ServoSidecarError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
})?;
Ok(exe_dir.join(sidecar_binary_name()))
}
fn sidecar_binary_name() -> String {
format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX)
}
fn temporary_rgba_path() -> Result<PathBuf, ServoSidecarError> {
let directory = env::temp_dir().join("ely-browser-servo");
fs::create_dir_all(&directory).map_err(ServoSidecarError::TempDirectory)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(directory.join(format!("frame-{}-{timestamp}.rgba", std::process::id())))
}
fn remove_temporary_file(path: &Path) -> Result<(), ServoSidecarError> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(ServoSidecarError::FrameCleanup(error)),
}
}
fn read_sidecar_snapshot(
stdout: &[u8],
rgba_path: &Path,
) -> Result<SidecarSnapshot, ServoSidecarError> {
let report: SidecarReport = serde_json::from_slice(stdout)?;
let rgba_bytes = fs::read(rgba_path).map_err(ServoSidecarError::FrameRead)?;
SidecarSnapshot::from_report(report, rgba_bytes)
}
fn expected_rgba_byte_count(width: u32, height: u32) -> Result<usize, ServoSidecarError> {
let byte_count = u64::from(width)
.checked_mul(u64::from(height))
.and_then(|pixels| pixels.checked_mul(4))
.ok_or(ServoSidecarError::RgbaByteCountOverflow { width, height })?;
usize::try_from(byte_count)
.map_err(|_| ServoSidecarError::RgbaByteCountOverflow { width, height })
}