use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::engine::{BrowserEngine, EngineEvent};
use crate::graphics::frame::{Frame, Rgba};
pub struct ServoShellEngine {
url: String,
last_size: Option<(u32, u32)>,
cached: Option<Frame>,
last_error: Option<String>,
}
impl ServoShellEngine {
pub fn new(url: String) -> Self {
Self {
url,
last_size: None,
cached: None,
last_error: None,
}
}
}
impl BrowserEngine for ServoShellEngine {
fn handle_event(&mut self, event: EngineEvent) {
if let EngineEvent::Resize { width, height } = event {
if self.last_size != Some((width, height)) {
self.cached = None;
}
}
}
fn render(&mut self, width: u32, height: u32) -> Frame {
if self.last_size == Some((width, height)) {
if let Some(frame) = &self.cached {
return frame.clone();
}
}
self.last_size = Some((width, height));
match render_with_servoshell(&self.url, width, height) {
Ok(frame) => {
self.cached = Some(frame.clone());
self.last_error = None;
frame
}
Err(error) => {
self.last_error = Some(error);
self.cached = None;
render_error(
width,
height,
&self.url,
self.last_error.as_deref().unwrap_or(""),
)
}
}
}
}
fn render_with_servoshell(url: &str, width: u32, height: u32) -> Result<Frame, String> {
let binary = find_servoshell().ok_or_else(|| {
"servoshell not found. Set LECTOR_SERVO_SHELL=/path/to/servoshell or build Servo with ./mach build.".to_string()
})?;
let output_path = temporary_output_path();
let size = format!("{}x{}", width.max(64), height.max(64));
let output = Command::new(&binary)
.args([
"--headless",
"--exit",
"--window-size",
&size,
"--output",
output_path.to_string_lossy().as_ref(),
url,
])
.output()
.map_err(|err| format!("failed to run {}: {err}", binary.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let _ = fs::remove_file(&output_path);
return Err(format!(
"servoshell exited with {}. stderr: {} stdout: {}",
output.status, stderr, stdout
));
}
let bytes = fs::read(&output_path)
.map_err(|err| format!("failed to read Servo output image {:?}: {err}", output_path))?;
let _ = fs::remove_file(&output_path);
parse_ppm(&bytes)
}
fn find_servoshell() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("LECTOR_SERVO_SHELL") {
return Some(PathBuf::from(path));
}
for name in ["servoshell", "servo"] {
if let Some(path) = find_in_path(name) {
return Some(path);
}
}
None
}
fn find_in_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn temporary_output_path() -> PathBuf {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("lector-servo-{}-{now}.ppm", std::process::id()))
}
fn parse_ppm(bytes: &[u8]) -> Result<Frame, String> {
let mut cursor = PpmCursor::new(bytes);
let magic = cursor.token().ok_or("missing PPM magic")?;
if magic != "P6" {
return Err(format!(
"unsupported Servo output format {magic}; expected P6 PPM"
));
}
let width = cursor
.token()
.ok_or("missing PPM width")?
.parse::<u32>()
.map_err(|err| format!("invalid PPM width: {err}"))?;
let height = cursor
.token()
.ok_or("missing PPM height")?
.parse::<u32>()
.map_err(|err| format!("invalid PPM height: {err}"))?;
let max = cursor
.token()
.ok_or("missing PPM max value")?
.parse::<u32>()
.map_err(|err| format!("invalid PPM max value: {err}"))?;
if max == 0 || max > 255 {
return Err(format!("unsupported PPM max value {max}; expected 1..255"));
}
cursor.skip_single_whitespace();
let expected = width as usize * height as usize * 3;
if cursor.remaining().len() < expected {
return Err("PPM pixel data is shorter than expected".to_string());
}
let mut frame = Frame::new(width, height, Rgba::rgb(0, 0, 0));
for y in 0..height {
for x in 0..width {
let index = ((y * width + x) * 3) as usize;
let data = cursor.remaining();
let scale = |value: u8| -> u8 {
if max == 255 {
value
} else {
((value as u32 * 255 + max / 2) / max) as u8
}
};
frame.set(
x as i32,
y as i32,
Rgba::rgb(
scale(data[index]),
scale(data[index + 1]),
scale(data[index + 2]),
),
);
}
}
Ok(frame)
}
struct PpmCursor<'a> {
bytes: &'a [u8],
offset: usize,
}
impl<'a> PpmCursor<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, offset: 0 }
}
fn token(&mut self) -> Option<String> {
self.skip_ws_and_comments();
let start = self.offset;
while self.offset < self.bytes.len() && !self.bytes[self.offset].is_ascii_whitespace() {
self.offset += 1;
}
(self.offset > start)
.then(|| String::from_utf8_lossy(&self.bytes[start..self.offset]).into_owned())
}
fn skip_ws_and_comments(&mut self) {
loop {
while self.offset < self.bytes.len() && self.bytes[self.offset].is_ascii_whitespace() {
self.offset += 1;
}
if self.offset < self.bytes.len() && self.bytes[self.offset] == b'#' {
while self.offset < self.bytes.len() && self.bytes[self.offset] != b'\n' {
self.offset += 1;
}
} else {
break;
}
}
}
fn skip_single_whitespace(&mut self) {
if self.offset < self.bytes.len() && self.bytes[self.offset].is_ascii_whitespace() {
self.offset += 1;
}
}
fn remaining(&self) -> &'a [u8] {
&self.bytes[self.offset..]
}
}
fn render_error(width: u32, height: u32, url: &str, error: &str) -> Frame {
let mut frame = Frame::new(width, height, Rgba::rgb(30, 32, 34));
frame.draw_text(24, 28, "SERVOSHELL BACKEND", Rgba::rgb(236, 238, 240));
frame.draw_text(24, 56, url, Rgba::rgb(174, 184, 194));
frame.draw_text(
24,
92,
"Install/build Servo servoshell, then set LECTOR_SERVO_SHELL.",
Rgba::rgb(236, 238, 240),
);
frame.draw_text(
24,
120,
"Expected command support: --headless --exit --window-size --output",
Rgba::rgb(174, 184, 194),
);
for (index, line) in error.as_bytes().chunks(96).take(8).enumerate() {
let line = String::from_utf8_lossy(line);
frame.draw_text(24, 164 + index as i32 * 18, &line, Rgba::rgb(224, 75, 52));
}
frame
}
#[cfg(test)]
mod tests {
use super::parse_ppm;
#[test]
fn parses_p6_ppm() {
let ppm = b"P6\n2 1\n255\n\xff\x00\x00\x00\xff\x00";
let frame = parse_ppm(ppm).unwrap();
assert_eq!(frame.width(), 2);
assert_eq!(frame.height(), 1);
assert_eq!(frame.pixels()[0].r, 255);
assert_eq!(frame.pixels()[1].g, 255);
}
}