Add Servo sidecar snapshot command

This commit is contained in:
2026-05-08 12:17:41 -04:00
parent 5f0773a043
commit fcdc2de076
6 changed files with 318 additions and 1 deletions
Generated
+2
View File
@@ -2286,6 +2286,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"dpi", "dpi",
"ely_domain", "ely_domain",
"serde",
"serde_json",
"servo", "servo",
"thiserror 2.0.18", "thiserror 2.0.18",
"url", "url",
+1
View File
@@ -24,6 +24,7 @@ servo = "0.1.0"
sha2 = "0.10.9" sha2 = "0.10.9"
semver = "1.0.28" semver = "1.0.28"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
thiserror = "2.0.12" thiserror = "2.0.12"
toml = "1.1.2" toml = "1.1.2"
url = "2.5.4" url = "2.5.4"
+8 -1
View File
@@ -7,11 +7,18 @@ rust-version.workspace = true
[features] [features]
default = [] default = []
servo-engine = ["dep:dpi", "dep:servo", "dep:url"] servo-engine = ["dep:dpi", "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] [dependencies]
dpi = { workspace = true, optional = true } dpi = { workspace = true, optional = true }
ely_domain = { path = "../ely_domain" } ely_domain = { path = "../ely_domain" }
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
servo = { workspace = true, optional = true } servo = { workspace = true, optional = true }
thiserror.workspace = true thiserror.workspace = true
url = { workspace = true, optional = true } url = { workspace = true, optional = true }
@@ -0,0 +1,247 @@
use std::{env, num::ParseIntError, path::PathBuf, thread, time::Duration};
use ely_domain::{ProfileId, TabId, UrlText};
use ely_servo_host::{
NavigationRequest, RenderedFrame, ServoHost, ServoHostError, ServoSurfaceSize,
SoftwareServoHost, WebViewSnapshot, WebViewState,
};
use serde::Serialize;
use thiserror::Error;
const WAIT_ITERATIONS: usize = 5_000;
const WAIT_INTERVAL: Duration = Duration::from_millis(2);
fn main() -> Result<(), SidecarError> {
match parse_command(env::args())? {
SidecarCommand::Snapshot(args) => run_snapshot(args),
}
}
enum SidecarCommand {
Snapshot(SnapshotArgs),
}
struct SnapshotArgs {
url: UrlText,
rgba_out: PathBuf,
width: u32,
height: u32,
}
#[derive(Debug, Error)]
enum SidecarError {
#[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 a positive integer: {value}")]
InvalidInteger {
name: &'static str,
value: String,
#[source]
source: ParseIntError,
},
#[error("{name} must be greater than zero")]
ZeroDimension { name: &'static str },
#[error("rgba output path is empty")]
EmptyRgbaOutputPath,
#[error("timed out rendering {url}: {snapshot:?}")]
RenderTimeout { url: String, snapshot: Box<WebViewSnapshot> },
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
#[error(transparent)]
Host(#[from] ServoHostError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
fn parse_command(args: impl IntoIterator<Item = String>) -> Result<SidecarCommand, SidecarError> {
let mut args = args.into_iter();
let _program_name = args.next();
let command = args.next().ok_or(SidecarError::MissingCommand)?;
match command.as_str() {
"snapshot" => parse_snapshot_args(args).map(SidecarCommand::Snapshot),
_ => Err(SidecarError::UnknownCommand { value: command }),
}
}
fn parse_snapshot_args(
args: impl IntoIterator<Item = String>,
) -> Result<SnapshotArgs, SidecarError> {
let mut args = args.into_iter();
let mut url = None;
let mut rgba_out = None;
let mut width = None;
let mut height = None;
while let Some(name) = args.next() {
match name.as_str() {
"--url" => url = Some(UrlText::parse(next_argument(&mut args, "--url")?)?),
"--rgba-out" => {
rgba_out = Some(parse_output_path(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")?)?)
}
_ => return Err(SidecarError::UnknownArgument { value: name }),
}
}
Ok(SnapshotArgs {
url: url.ok_or(SidecarError::MissingRequiredArgument { name: "--url" })?,
rgba_out: rgba_out.ok_or(SidecarError::MissingRequiredArgument { name: "--rgba-out" })?,
width: width.ok_or(SidecarError::MissingRequiredArgument { name: "--width" })?,
height: height.ok_or(SidecarError::MissingRequiredArgument { name: "--height" })?,
})
}
fn next_argument(
args: &mut impl Iterator<Item = String>,
name: &'static str,
) -> Result<String, SidecarError> {
args.next().ok_or(SidecarError::MissingArgumentValue { name })
}
fn parse_dimension(name: &'static str, value: String) -> Result<u32, SidecarError> {
let dimension = value.parse::<u32>().map_err(|source| SidecarError::InvalidInteger {
name,
value,
source,
})?;
if dimension == 0 {
return Err(SidecarError::ZeroDimension { name });
}
Ok(dimension)
}
fn parse_output_path(value: String) -> Result<PathBuf, SidecarError> {
if value.trim().is_empty() {
return Err(SidecarError::EmptyRgbaOutputPath);
}
Ok(PathBuf::from(value))
}
fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> {
let mut host = SoftwareServoHost::new(ServoSurfaceSize::new(args.width, args.height))?;
let tab_id = TabId::new();
let profile_id = ProfileId::new();
let webview_id = host.create_webview(tab_id.clone(), profile_id)?;
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 frame = host.last_rendered_frame()?;
std::fs::write(&args.rgba_out, frame.rgba_bytes())?;
serde_json::to_writer(
std::io::stdout().lock(),
&SnapshotReport::new(args.url.as_str(), &args.rgba_out, &snapshot, &frame),
)?;
Ok(())
}
fn wait_for_frame(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
url: &str,
) -> Result<WebViewSnapshot, SidecarError> {
for _ in 0..WAIT_ITERATIONS {
host.tick();
let snapshot = host.snapshot(webview_id)?;
if snapshot.has_pending_frame() {
host.paint(webview_id)?;
}
let snapshot = host.snapshot(webview_id)?;
let has_rendered_frame =
host.last_rendered_frame().is_ok_and(|frame| frame.non_white_pixel_count() > 0);
if snapshot.state() == &WebViewState::Complete && has_rendered_frame {
return Ok(snapshot);
}
thread::sleep(WAIT_INTERVAL);
}
Err(SidecarError::RenderTimeout {
url: url.to_string(),
snapshot: Box::new(host.snapshot(webview_id)?),
})
}
#[derive(Serialize)]
struct SnapshotReport {
requested_url: 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,
sample_hash: u64,
}
impl SnapshotReport {
fn new(
requested_url: &str,
rgba_path: &std::path::Path,
snapshot: &WebViewSnapshot,
frame: &RenderedFrame,
) -> Self {
Self {
requested_url: requested_url.to_string(),
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
rgba_path: rgba_path.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(),
sample_hash: frame.sample_hash(),
}
}
}
fn state_label(state: &WebViewState) -> &'static str {
match state {
WebViewState::Created => "created",
WebViewState::Loading => "loading",
WebViewState::Complete => "complete",
WebViewState::Sleeping => "sleeping",
WebViewState::Crashed => "crashed",
}
}
+56
View File
@@ -0,0 +1,56 @@
#![cfg(feature = "servo-engine")]
use std::{error::Error, process::Command};
const WIDTH: u64 = 640;
const HEIGHT: u64 = 480;
#[test]
fn sidecar_snapshots_prd_site_to_rgba_file() -> Result<(), Box<dyn Error>> {
let output_path =
std::env::temp_dir().join(format!("ely-servo-sidecar-{}-example.rgba", std::process::id()));
if output_path.exists() {
std::fs::remove_file(&output_path)?;
}
let output = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"))
.arg("snapshot")
.arg("--url")
.arg("https://example.com")
.arg("--rgba-out")
.arg(&output_path)
.arg("--width")
.arg(WIDTH.to_string())
.arg("--height")
.arg(HEIGHT.to_string())
.output()?;
assert!(
output.status.success(),
"status: {:?}\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")?, WIDTH);
assert_eq!(report_field_as_u64(&report, "height")?, HEIGHT);
assert_eq!(report_field_as_u64(&report, "rgba_byte_count")?, WIDTH * HEIGHT * 4);
assert!(report_field_as_u64(&report, "non_white_pixel_count")? > 0);
assert!(report_field_as_u64(&report, "sample_hash")? > 0);
assert_eq!(std::fs::metadata(&output_path)?.len(), WIDTH * HEIGHT * 4);
std::fs::remove_file(&output_path)?;
Ok(())
}
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())
}
+4
View File
@@ -3,17 +3,21 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
binary_path="${repo_root}/target/debug/ely_app" binary_path="${repo_root}/target/debug/ely_app"
sidecar_path="${repo_root}/target/debug/ely_servo_sidecar"
bundle_root="${repo_root}/target/macos/ELY Browser.app" bundle_root="${repo_root}/target/macos/ELY Browser.app"
contents_dir="${bundle_root}/Contents" contents_dir="${bundle_root}/Contents"
macos_dir="${contents_dir}/MacOS" macos_dir="${contents_dir}/MacOS"
resources_dir="${contents_dir}/Resources" resources_dir="${contents_dir}/Resources"
cargo build -p ely_app cargo build -p ely_app
cargo build -p ely_servo_host --features servo-engine --bin ely_servo_sidecar
rm -rf "${bundle_root}" rm -rf "${bundle_root}"
mkdir -p "${macos_dir}" "${resources_dir}" mkdir -p "${macos_dir}" "${resources_dir}"
cp "${repo_root}/packaging/macos/Info.plist" "${contents_dir}/Info.plist" cp "${repo_root}/packaging/macos/Info.plist" "${contents_dir}/Info.plist"
cp "${binary_path}" "${macos_dir}/ely_app" cp "${binary_path}" "${macos_dir}/ely_app"
cp "${sidecar_path}" "${macos_dir}/ely_servo_sidecar"
chmod 755 "${macos_dir}/ely_app" chmod 755 "${macos_dir}/ely_app"
chmod 755 "${macos_dir}/ely_servo_sidecar"
echo "${bundle_root}" echo "${bundle_root}"