lector-browser 0.1.0

A high-performance graphical terminal web browser targeting Sixel first.
//! ServoShell backend via `servoshell` headless image output.
//!
//! Servo is not yet exposed as a small stable Rust webview crate. The current
//! official route for running Servo as a browser is `servoshell`, which supports
//! headless rendering to an image file. Lector uses that as the first real Servo
//! integration path: render URL -> PPM file -> RGBA frame -> terminal image.
//!
//! A native in-process Servo backend is possible, but it needs Servo as a full
//! workspace dependency/vendor tree. The HTML/CSS renderer is not separable from
//! Servo's script, style, layout, constellation, compositor, WebRender/surfman,
//! SpiderMonkey, and resource plumbing.

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);
    }
}