diff --git a/Cargo.lock b/Cargo.lock index 0785bde..74ed358 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2286,6 +2286,8 @@ version = "0.1.0" dependencies = [ "dpi", "ely_domain", + "serde", + "serde_json", "servo", "thiserror 2.0.18", "url", diff --git a/Cargo.toml b/Cargo.toml index f07de41..aadf8f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ servo = "0.1.0" sha2 = "0.10.9" semver = "1.0.28" serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" thiserror = "2.0.12" toml = "1.1.2" url = "2.5.4" diff --git a/crates/ely_servo_host/Cargo.toml b/crates/ely_servo_host/Cargo.toml index 7d62463..49d6fb6 100644 --- a/crates/ely_servo_host/Cargo.toml +++ b/crates/ely_servo_host/Cargo.toml @@ -7,11 +7,18 @@ rust-version.workspace = true [features] 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] dpi = { workspace = true, optional = true } ely_domain = { path = "../ely_domain" } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } servo = { workspace = true, optional = true } thiserror.workspace = true url = { workspace = true, optional = true } diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs new file mode 100644 index 0000000..3c19dfa --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs @@ -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 }, + + #[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) -> Result { + 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, +) -> Result { + 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, + name: &'static str, +) -> Result { + args.next().ok_or(SidecarError::MissingArgumentValue { name }) +} + +fn parse_dimension(name: &'static str, value: String) -> Result { + let dimension = value.parse::().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 { + 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 { + 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, + title: Option, + 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", + } +} diff --git a/crates/ely_servo_host/tests/sidecar.rs b/crates/ely_servo_host/tests/sidecar.rs new file mode 100644 index 0000000..c2dd350 --- /dev/null +++ b/crates/ely_servo_host/tests/sidecar.rs @@ -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> { + 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> { + report + .get(field) + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| format!("missing numeric report field: {field}").into()) +} diff --git a/scripts/create_macos_app_bundle.sh b/scripts/create_macos_app_bundle.sh index 0977a75..3235426 100755 --- a/scripts/create_macos_app_bundle.sh +++ b/scripts/create_macos_app_bundle.sh @@ -3,17 +3,21 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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" contents_dir="${bundle_root}/Contents" macos_dir="${contents_dir}/MacOS" resources_dir="${contents_dir}/Resources" cargo build -p ely_app +cargo build -p ely_servo_host --features servo-engine --bin ely_servo_sidecar rm -rf "${bundle_root}" mkdir -p "${macos_dir}" "${resources_dir}" cp "${repo_root}/packaging/macos/Info.plist" "${contents_dir}/Info.plist" 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_servo_sidecar" echo "${bundle_root}"