use std::io::{BufRead, BufReader, ErrorKind};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStderr, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak};
use std::thread;
use std::time::{Duration, Instant};
use std::{fmt, io};
use rich_intuituive::interact::{Button, Key, KeyCode, ModifierKey, Modifiers, Mouse, MouseKind};
use serde_json::{Value, json};
use tungstenite::{Message, WebSocket};
use crate::host::Notify;
use crate::pixels::Pixels;
use crate::web::{PageState, WebEngine, WebFrame, WebInput};
pub const BINARY_NAMES: &[&str] = &[
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"chrome",
"headless_shell",
"microsoft-edge",
];
const KNOWN_PATHS: &[&str] = &[
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Wire {
#[cfg(unix)]
Pipe,
#[cfg_attr(all(unix, not(test)), allow(dead_code))]
Port,
}
impl Wire {
#[cfg(unix)]
const DEFAULT: Wire = Wire::Pipe;
#[cfg(not(unix))]
const DEFAULT: Wire = Wire::Port;
fn switch(self) -> &'static str {
match self {
#[cfg(unix)]
Wire::Pipe => "--remote-debugging-pipe",
Wire::Port => "--remote-debugging-port=0",
}
}
}
const START_TIMEOUT: Duration = Duration::from_secs(30);
const POLL: Duration = Duration::from_millis(15);
#[derive(Clone, Debug, PartialEq)]
enum Request {
Open(String),
Resize(u16, u16),
Input(WebInput),
Back,
Forward,
Reload,
Screencast(bool),
ScreencastScale(f64),
Ack(i64),
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct FrameMeta {
pub scroll_x: f64,
pub scroll_y: f64,
pub offset_top: f64,
pub page_scale: f64,
pub device_width: f64,
pub device_height: f64,
pub timestamp: Option<f64>,
}
impl FrameMeta {
fn of(metadata: &Value) -> FrameMeta {
let f = |key: &str, default: f64| metadata[key].as_f64().unwrap_or(default);
FrameMeta {
scroll_x: f("scrollOffsetX", 0.0),
scroll_y: f("scrollOffsetY", 0.0),
offset_top: f("offsetTop", 0.0),
page_scale: f("pageScaleFactor", 1.0),
device_width: f("deviceWidth", 0.0),
device_height: f("deviceHeight", 0.0),
timestamp: metadata["timestamp"].as_f64(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ScreencastFrame {
pub pixels: Pixels,
pub meta: FrameMeta,
}
impl ScreencastFrame {
pub fn crop(&self, x: f64, y: f64, width: f64, height: f64) -> Option<Pixels> {
let meta = &self.meta;
let device = if meta.device_width > 0.0 {
meta.device_width
} else {
self.pixels.width as f64
};
let scale = self.pixels.width as f64 / device * meta.page_scale.max(f64::MIN_POSITIVE);
let left = (x - meta.scroll_x) * scale;
let top = (y - meta.scroll_y) * scale + meta.offset_top;
let x0 = left.max(0.0).floor();
let y0 = top.max(0.0).floor();
let x1 = ((left + width * scale).ceil()).min(self.pixels.width as f64);
let y1 = ((top + height * scale).ceil()).min(self.pixels.height as f64);
if x1 <= x0 || y1 <= y0 {
return None;
}
self.pixels
.crop(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)
}
}
type RawFrame = (String, FrameMeta, Option<i64>);
fn keep_frame(
waiting: &mut Option<RawFrame>,
data: String,
meta: FrameMeta,
ack: i64,
) -> Option<i64> {
let ahead = waiting.is_none();
*waiting = Some((data, meta, (!ahead).then_some(ack)));
ahead.then_some(ack)
}
#[derive(Default)]
struct Shared {
frame: Option<Pixels>,
meta: FrameMeta,
raw: Option<RawFrame>,
state: PageState,
notify: Option<Notify>,
child: Option<Child>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Downloads {
#[default]
Deny,
Quarantine(PathBuf),
}
#[derive(Clone, Debug, PartialEq)]
pub struct DevToolsEvent {
pub method: String,
pub params: Value,
}
type Listeners = Arc<Mutex<Vec<(Vec<String>, Sender<DevToolsEvent>)>>>;
struct Call {
method: String,
params: Value,
answer: Sender<io::Result<Value>>,
}
#[derive(Clone, Debug)]
pub struct DevTools {
calls: Sender<Call>,
listeners: Listeners,
}
impl DevTools {
pub fn call(&self, method: &str, params: Value, timeout: Duration) -> io::Result<Value> {
let (answer, wait) = mpsc::channel();
let call = Call {
method: method.to_string(),
params,
answer,
};
self.calls
.send(call)
.map_err(|_| io::Error::new(ErrorKind::BrokenPipe, "the browser has gone"))?;
match wait.recv_timeout(timeout) {
Ok(result) => result,
Err(mpsc::RecvTimeoutError::Timeout) => Err(io::Error::new(
ErrorKind::TimedOut,
format!("{method}: no answer in time"),
)),
Err(mpsc::RecvTimeoutError::Disconnected) => Err(io::Error::new(
ErrorKind::BrokenPipe,
"the browser has gone",
)),
}
}
pub fn listen(&self, methods: &[&str]) -> Receiver<DevToolsEvent> {
let (send, receive) = mpsc::channel();
let methods = methods.iter().map(|m| m.to_string()).collect();
self.listeners
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push((methods, send));
receive
}
}
fn deliver(listeners: &Listeners, method: &str, params: &Value) {
let mut listeners = listeners.lock().unwrap_or_else(PoisonError::into_inner);
listeners.retain(|(methods, send)| {
if !methods.iter().any(|m| m == method) {
return true;
}
send.send(DevToolsEvent {
method: method.to_string(),
params: params.clone(),
})
.is_ok()
});
}
fn lock(shared: &Mutex<Shared>) -> MutexGuard<'_, Shared> {
shared.lock().unwrap_or_else(PoisonError::into_inner)
}
fn notify(shared: &Mutex<Shared>) {
let notify = lock(shared).notify.clone();
if let Some(notify) = notify {
notify();
}
}
pub struct ChromeEngine {
binary: Option<PathBuf>,
args: Vec<String>,
cell: (u32, u32),
shared: Arc<Mutex<Shared>>,
commands: Option<Sender<Request>>,
calls: Option<Sender<Call>>,
profile: Option<PathBuf>,
kept_profile: Option<PathBuf>,
audio: bool,
downloads: Downloads,
listeners: Listeners,
wire: Wire,
screencast: bool,
on_demand: bool,
scale: f64,
}
impl fmt::Debug for ChromeEngine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ChromeEngine")
.field("binary", &self.binary)
.field("cell", &self.cell)
.finish()
}
}
impl Default for ChromeEngine {
fn default() -> Self {
ChromeEngine::new()
}
}
impl ChromeEngine {
pub fn new() -> ChromeEngine {
ChromeEngine {
binary: None,
args: Vec::new(),
cell: (10, 20),
shared: Arc::default(),
commands: None,
calls: None,
profile: None,
kept_profile: None,
audio: false,
downloads: Downloads::Deny,
listeners: Arc::default(),
wire: Wire::DEFAULT,
screencast: true,
on_demand: false,
scale: 1.0,
}
}
#[must_use]
pub fn audio(mut self, on: bool) -> ChromeEngine {
self.audio = on;
self
}
#[must_use]
pub fn downloads(mut self, downloads: Downloads) -> ChromeEngine {
self.downloads = downloads;
self
}
#[must_use]
pub fn profile(mut self, dir: impl Into<PathBuf>) -> ChromeEngine {
self.kept_profile = Some(dir.into());
self
}
#[must_use]
pub fn binary(mut self, path: impl Into<PathBuf>) -> ChromeEngine {
self.binary = Some(path.into());
self
}
#[must_use]
pub fn args<I: IntoIterator<Item = S>, S: Into<String>>(mut self, args: I) -> ChromeEngine {
self.args.extend(
args.into_iter()
.map(Into::into)
.filter(|arg| !turns_sandbox_off(arg) && !moves_devtools(arg)),
);
self
}
#[must_use]
pub fn screencast(mut self, on: bool) -> ChromeEngine {
self.screencast = on;
self
}
pub fn set_screencast(&mut self, on: bool) -> io::Result<()> {
self.screencast = on;
if self.commands.is_none() {
return Ok(());
}
self.send(Request::Screencast(on))
}
#[must_use]
pub fn screencast_on_demand(mut self, on: bool) -> ChromeEngine {
self.on_demand = on;
self
}
pub fn set_screencast_scale(&mut self, scale: f64) -> io::Result<()> {
let scale = if scale.is_finite() {
scale.clamp(0.01, 1.0)
} else {
1.0
};
if (scale - self.scale).abs() < f64::EPSILON {
return Ok(());
}
self.scale = scale;
if self.commands.is_none() {
return Ok(());
}
self.send(Request::ScreencastScale(scale))
}
pub fn take_screencast(&mut self) -> Option<ScreencastFrame> {
let (raw, decoded) = {
let mut shared = lock(&self.shared);
let meta = shared.meta;
(shared.raw.take(), shared.frame.take().map(|p| (p, meta)))
};
if let Some((data, meta, ack)) = raw {
if let Some(ack) = ack {
let _ = self.send(Request::Ack(ack));
}
return decode_frame(&data).map(|pixels| ScreencastFrame { pixels, meta });
}
decoded.map(|(pixels, meta)| ScreencastFrame { pixels, meta })
}
#[must_use]
pub fn cell_pixels(mut self, width: u32, height: u32) -> ChromeEngine {
self.cell = (width.max(1), height.max(1));
self
}
pub fn end_all() {
let all = std::mem::take(&mut *RUNNING.lock().unwrap_or_else(PoisonError::into_inner));
for running in all {
if let Some(shared) = running.shared.upgrade() {
let child = lock(&shared).child.take();
if let Some(mut child) = child {
end_browser(&mut child);
}
}
if let Some(profile) = running.profile {
remove_profile(&profile);
}
}
}
pub fn find() -> Option<PathBuf> {
let path = std::env::var_os("PATH").unwrap_or_default();
for name in BINARY_NAMES {
for dir in std::env::split_paths(&path) {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
let exe = candidate.with_extension("exe");
if cfg!(windows) && exe.is_file() {
return Some(exe);
}
}
}
KNOWN_PATHS
.iter()
.map(PathBuf::from)
.find(|path| path.is_file())
}
pub fn devtools(&mut self) -> io::Result<DevTools> {
if self.commands.is_none() {
self.start()?;
}
let calls = self
.calls
.clone()
.ok_or_else(|| io::Error::new(ErrorKind::BrokenPipe, "the browser has gone"))?;
Ok(DevTools {
calls,
listeners: Arc::clone(&self.listeners),
})
}
fn fail(&self, message: impl Into<String>) {
lock(&self.shared).state.error = Some(message.into());
notify(&self.shared);
}
fn start(&mut self) -> io::Result<()> {
let Some(binary) = self.binary.clone().or_else(ChromeEngine::find) else {
let message = format!(
"no Chrome or Chromium found: install one, or name it with \
ChromeEngine::binary (looked for {})",
BINARY_NAMES.join(", ")
);
self.fail(message.clone());
return Err(io::Error::new(ErrorKind::NotFound, message));
};
let profile = match &self.kept_profile {
Some(dir) => {
private_dir(dir)?;
dir.clone()
}
None => temporary_profile()?,
};
if let Downloads::Quarantine(dir) = &self.downloads {
private_dir(dir)?;
}
let mut command = Command::new(&binary);
command
.args(launch_args(
&profile, &self.args, 80, 24, self.cell, self.wire, self.audio,
))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
#[cfg(unix)]
std::os::unix::process::CommandExt::process_group(&mut command, 0);
let spawned = connect(&mut command, self.wire).and_then(|connect| {
let child = command.spawn()?;
Ok((child, connect))
});
drop(command);
let (mut child, connect) = match spawned {
Ok(spawned) => spawned,
Err(error) => {
if self.kept_profile.is_none() {
let _ = std::fs::remove_dir_all(&profile);
}
return Err(error);
}
};
self.profile = Some(profile.clone());
RUNNING
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(Running {
shared: Arc::downgrade(&self.shared),
profile: self.kept_profile.is_none().then(|| profile.clone()),
});
let stderr = child.stderr.take().expect("piped stderr");
lock(&self.shared).child = Some(child);
let (sender, receiver) = mpsc::channel();
self.commands = Some(sender);
let (calls, called) = mpsc::channel();
self.calls = Some(calls);
let shared = Arc::clone(&self.shared);
let cell = self.cell;
let wiring = Wiring {
downloads: self.downloads.clone(),
listeners: Arc::clone(&self.listeners),
screencast: self.screencast,
on_demand: self.on_demand,
scale: self.scale,
};
thread::spawn(move || {
if let Err(error) = run(connect, stderr, receiver, called, &shared, cell, wiring) {
let mut state = lock(&shared);
state.state.error = Some(format!("Chrome: {error}"));
state.state.loading = false;
drop(state);
notify(&shared);
}
});
Ok(())
}
fn send(&mut self, command: Request) -> io::Result<()> {
if self.commands.is_none() {
self.start()?;
}
if let Some(commands) = &self.commands {
commands
.send(command)
.map_err(|_| io::Error::new(ErrorKind::BrokenPipe, "the browser has gone"))?;
}
Ok(())
}
}
struct Running {
shared: Weak<Mutex<Shared>>,
profile: Option<PathBuf>,
}
static RUNNING: Mutex<Vec<Running>> = Mutex::new(Vec::new());
impl Drop for ChromeEngine {
fn drop(&mut self) {
RUNNING
.lock()
.unwrap_or_else(PoisonError::into_inner)
.retain(|running| !std::ptr::eq(running.shared.as_ptr(), Arc::as_ptr(&self.shared)));
self.commands = None;
self.calls = None;
let child = lock(&self.shared).child.take();
if let Some(mut child) = child {
end_browser(&mut child);
}
if let Some(profile) = &self.profile
&& self.kept_profile.is_none()
{
remove_profile(profile);
}
}
}
fn end_browser(child: &mut Child) {
kill_group(child);
let _ = child.kill();
let _ = child.wait();
}
#[cfg(unix)]
#[allow(unsafe_code)]
fn kill_group(child: &Child) {
if let Ok(group) = libc::pid_t::try_from(child.id()) {
unsafe {
libc::killpg(group, libc::SIGKILL);
}
}
}
#[cfg(not(unix))]
fn kill_group(_child: &Child) {}
fn remove_profile(profile: &std::path::Path) {
for _ in 0..20 {
let _ = std::fs::remove_dir_all(profile);
if !profile.exists() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
impl WebEngine for ChromeEngine {
fn open(&mut self, url: &str) -> io::Result<()> {
{
let mut shared = lock(&self.shared);
shared.state.loading = true;
shared.state.error = None;
}
self.send(Request::Open(url.to_string()))
}
fn resize(&mut self, columns: u16, rows: u16) -> io::Result<()> {
self.send(Request::Resize(columns, rows))
}
fn input(&mut self, input: WebInput) -> io::Result<()> {
self.send(Request::Input(input))
}
fn back(&mut self) -> io::Result<()> {
self.send(Request::Back)
}
fn forward(&mut self) -> io::Result<()> {
self.send(Request::Forward)
}
fn reload(&mut self) -> io::Result<()> {
self.send(Request::Reload)
}
fn poll(&mut self) -> Option<WebFrame> {
lock(&self.shared).frame.take().map(WebFrame::Pixels)
}
fn state(&self) -> PageState {
lock(&self.shared).state.clone()
}
fn set_notify(&mut self, notify: Notify) {
lock(&self.shared).notify = Some(notify);
}
}
enum Connect {
Port,
#[cfg(unix)]
Pipe(io::PipeWriter, io::PipeReader),
}
#[cfg_attr(not(unix), allow(unused_variables))]
fn connect(command: &mut Command, wire: Wire) -> io::Result<Connect> {
match wire {
#[cfg(unix)]
Wire::Pipe => {
use command_fds::{CommandFdExt, FdMapping};
let (browser_reads, we_write) = io::pipe()?;
let (we_read, browser_writes) = io::pipe()?;
command
.fd_mappings(vec![
FdMapping {
parent_fd: browser_reads.into(),
child_fd: 3,
},
FdMapping {
parent_fd: browser_writes.into(),
child_fd: 4,
},
])
.map_err(|_| io::Error::other("descriptors 3 and 4 collide"))?;
Ok(Connect::Pipe(we_write, we_read))
}
Wire::Port => Ok(Connect::Port),
}
}
fn temporary_profile() -> io::Result<PathBuf> {
static NEXT: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
loop {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"rich-embed-chrome-{}-{n}-{nanos}",
std::process::id()
));
#[cfg_attr(not(unix), allow(unused_mut))]
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
match builder.create(&dir) {
Ok(()) => {
#[cfg(test)]
tests::MADE.with(|made| made.borrow_mut().push(dir.clone()));
return Ok(dir);
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
}
fn private_dir(dir: &Path) -> io::Result<()> {
if dir.is_dir() {
return Ok(());
}
#[cfg_attr(not(unix), allow(unused_mut))]
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir)
}
pub(crate) fn turns_sandbox_off(arg: &str) -> bool {
let name = arg.trim_start_matches(|c| c == '-' || (cfg!(windows) && c == '/'));
if name.len() == arg.len() {
return false;
}
let name = name
.split('=')
.next()
.unwrap_or_default()
.to_ascii_lowercase();
((name.starts_with("no-") || name.starts_with("disable-")) && name.contains("sandbox"))
|| matches!(name.as_str(), "single-process" | "no-zygote")
}
pub(crate) fn moves_devtools(arg: &str) -> bool {
let name = arg.trim_start_matches(|c| c == '-' || (cfg!(windows) && c == '/'));
name.len() != arg.len() && name.to_ascii_lowercase().starts_with("remote-debugging-")
}
pub(crate) fn launch_args(
profile: &Path,
extra: &[String],
columns: u16,
rows: u16,
cell: (u32, u32),
wire: Wire,
audio: bool,
) -> Vec<String> {
let (width, height) = viewport(columns, rows, cell);
let mut args = vec![
"--headless=new".to_string(),
wire.switch().to_string(),
format!("--user-data-dir={}", profile.display()),
format!("--window-size={width},{height}"),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
"--disable-extensions".to_string(),
"--disable-background-networking".to_string(),
"--disable-sync".to_string(),
"--hide-scrollbars".to_string(),
];
if !audio {
args.push("--mute-audio".to_string());
}
args.extend(
extra
.iter()
.filter(|arg| !turns_sandbox_off(arg) && !moves_devtools(arg))
.cloned(),
);
args.push("about:blank".to_string());
args
}
fn viewport(columns: u16, rows: u16, (cw, ch): (u32, u32)) -> (u32, u32) {
(columns.max(1) as u32 * cw, rows.max(1) as u32 * ch)
}
fn scaled((width, height): (u32, u32), scale: f64) -> (u32, u32) {
let by = |n: u32| ((f64::from(n) * scale).ceil() as u32).clamp(1, n.max(1));
(by(width), by(height))
}
pub(crate) fn devtools_url(line: &str) -> Option<String> {
let at = line.find("ws://")?;
let url = line[at..].trim();
url.contains("/devtools/browser/").then(|| url.to_string())
}
pub(crate) fn command(id: u64, method: &str, params: Value, session: Option<&str>) -> String {
let mut message = json!({"id": id, "method": method, "params": params});
if let Some(session) = session {
message["sessionId"] = Value::String(session.to_string());
}
message.to_string()
}
fn modifier_bits(modifiers: Modifiers) -> u8 {
modifiers.alt as u8 + 2 * modifiers.ctrl as u8 + 8 * modifiers.shift as u8
}
fn key_parts(key: Key) -> (String, u32, Option<String>) {
let named = |name: &str, code: u32| (name.to_string(), code, None);
match key.code {
KeyCode::Char(c) => {
let upper = c.to_ascii_uppercase();
let code = if upper.is_ascii_alphanumeric() {
upper as u32
} else if c == ' ' {
32
} else {
0
};
let text = (!key.modifiers.ctrl && !key.modifiers.alt).then(|| c.to_string());
(c.to_string(), code, text)
}
KeyCode::Enter => ("Enter".into(), 13, Some("\r".into())),
KeyCode::Tab => named("Tab", 9),
KeyCode::BackTab => named("Tab", 9),
KeyCode::Backspace => named("Backspace", 8),
KeyCode::Delete => named("Delete", 46),
KeyCode::Insert => named("Insert", 45),
KeyCode::Escape => named("Escape", 27),
KeyCode::Up => named("ArrowUp", 38),
KeyCode::Down => named("ArrowDown", 40),
KeyCode::Left => named("ArrowLeft", 37),
KeyCode::Right => named("ArrowRight", 39),
KeyCode::Home => named("Home", 36),
KeyCode::End => named("End", 35),
KeyCode::PageUp => named("PageUp", 33),
KeyCode::PageDown => named("PageDown", 34),
KeyCode::Menu => named("ContextMenu", 93),
KeyCode::Modifier(which) => match which {
ModifierKey::Shift => named("Shift", 16),
ModifierKey::Ctrl => named("Control", 17),
ModifierKey::Alt => named("Alt", 18),
ModifierKey::Super => named("Meta", 91),
},
KeyCode::F(n) => (format!("F{n}"), 111 + n as u32, None),
}
}
pub fn key_events(key: Key) -> Vec<Value> {
let (name, code, text) = key_parts(key);
let mut modifiers = modifier_bits(key.modifiers);
if key.code == KeyCode::BackTab {
modifiers |= 8;
}
let mut down = json!({
"type": if text.is_some() { "keyDown" } else { "rawKeyDown" },
"key": name,
"windowsVirtualKeyCode": code,
"modifiers": modifiers,
});
if let Some(text) = text {
down["text"] = Value::String(text.clone());
down["unmodifiedText"] = Value::String(text);
}
let up = json!({
"type": "keyUp",
"key": name,
"windowsVirtualKeyCode": code,
"modifiers": modifiers,
});
vec![down, up]
}
pub(crate) fn mouse_event(mouse: Mouse, (cw, ch): (u32, u32)) -> Value {
let x = mouse.column as f64 * cw as f64 + cw as f64 / 2.0;
let y = mouse.row as f64 * ch as f64 + ch as f64 / 2.0;
let button = |b: Button| match b {
Button::Left => "left",
Button::Middle => "middle",
Button::Right => "right",
};
let (kind, button, clicks) = match mouse.kind {
MouseKind::Down(b) => ("mousePressed", button(b), 1),
MouseKind::Up(b) => ("mouseReleased", button(b), 1),
MouseKind::Drag(b) => ("mouseMoved", button(b), 0),
MouseKind::Moved => ("mouseMoved", "none", 0),
MouseKind::ScrollUp | MouseKind::ScrollDown => ("mouseWheel", "none", 0),
};
let mut event = json!({
"type": kind,
"x": x,
"y": y,
"button": button,
"clickCount": clicks,
"modifiers": modifier_bits(mouse.modifiers),
});
if kind == "mouseWheel" {
let rows = if mouse.kind == MouseKind::ScrollUp {
-3.0
} else {
3.0
};
event["deltaX"] = json!(0);
event["deltaY"] = json!(rows * ch as f64);
}
event
}
pub(crate) fn base64_decode(text: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(text.len() / 4 * 3);
let mut buffer = 0u32;
let mut bits = 0;
for byte in text.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' => break,
b'\r' | b'\n' => continue,
_ => return None,
};
buffer = buffer << 6 | value as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buffer >> bits) as u8);
buffer &= (1 << bits) - 1;
}
}
Some(out)
}
fn decode_frame(data: &str) -> Option<Pixels> {
let bytes = base64_decode(data)?;
let image = image::load_from_memory(&bytes).ok()?.to_rgb8();
Pixels::new(image.width(), image.height(), image.into_raw())
}
fn devtools_address(stderr: ChildStderr) -> io::Result<String> {
let (found, wait) = mpsc::channel();
thread::spawn(move || {
let mut found = Some(found);
let mut last = String::new();
for line in BufReader::new(stderr).lines() {
let Ok(line) = line else { break };
if let Some(url) = devtools_url(&line) {
if let Some(found) = found.take() {
let _ = found.send(Ok(url));
}
} else if found.is_some() && !line.trim().is_empty() {
last = line;
}
}
if let Some(found) = found {
let _ = found.send(Err(last));
}
});
match wait.recv_timeout(START_TIMEOUT) {
Ok(Ok(url)) => Ok(url),
Ok(Err(last)) => Err(io::Error::other(if last.is_empty() {
"the browser exited before DevTools started".to_string()
} else {
format!("the browser exited before DevTools started: {last}")
})),
Err(_) => Err(io::Error::new(
ErrorKind::TimedOut,
"the browser did not start DevTools in time",
)),
}
}
#[cfg(unix)]
fn drain(stderr: ChildStderr) -> Receiver<String> {
let (last_line, wait) = mpsc::channel();
thread::spawn(move || {
let mut last = String::new();
for line in BufReader::new(stderr).lines() {
let Ok(line) = line else { break };
if !line.trim().is_empty() {
last = line;
}
}
let _ = last_line.send(last);
});
wait
}
enum Transport {
WebSocket(Box<WebSocket<TcpStream>>),
#[cfg(unix)]
Pipe {
write: io::PipeWriter,
read: Receiver<Vec<u8>>,
stderr: Receiver<String>,
},
}
impl Transport {
fn websocket(stderr: ChildStderr) -> io::Result<Transport> {
let url = devtools_address(stderr)?;
let address = url
.trim_start_matches("ws://")
.split('/')
.next()
.unwrap_or_default()
.to_string();
let stream = TcpStream::connect(&address)?;
let (socket, _) = tungstenite::client::client(url.as_str(), stream)
.map_err(|error| io::Error::other(error.to_string()))?;
socket.get_ref().set_read_timeout(Some(POLL))?;
Ok(Transport::WebSocket(Box::new(socket)))
}
#[cfg(unix)]
fn pipe(write: io::PipeWriter, read: io::PipeReader, stderr: ChildStderr) -> Transport {
let (messages, receive) = mpsc::channel();
thread::spawn(move || {
let mut read = BufReader::new(read);
loop {
let mut message = Vec::new();
match read.read_until(0, &mut message) {
Ok(0) | Err(_) => break,
Ok(_) => {
if message.last() == Some(&0) {
message.pop();
}
if messages.send(message).is_err() {
break;
}
}
}
}
});
Transport::Pipe {
write,
read: receive,
stderr: drain(stderr),
}
}
fn send(&mut self, text: &str) -> io::Result<()> {
match self {
Transport::WebSocket(socket) => socket
.send(Message::text(text))
.map_err(|error| io::Error::other(error.to_string())),
#[cfg(unix)]
Transport::Pipe { write, .. } => {
let mut bytes = Vec::with_capacity(text.len() + 1);
bytes.extend_from_slice(text.as_bytes());
bytes.push(0);
io::Write::write_all(write, &bytes)?;
io::Write::flush(write)
}
}
}
fn read(&mut self) -> io::Result<Option<String>> {
match self {
Transport::WebSocket(socket) => match socket.read() {
Ok(Message::Text(text)) => Ok(Some(text.as_str().to_string())),
Ok(Message::Close(_)) => Err(io::Error::other("the browser closed")),
Ok(_) => Ok(None),
Err(tungstenite::Error::Io(error))
if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) =>
{
Ok(None)
}
Err(error) => Err(io::Error::other(error.to_string())),
},
#[cfg(unix)]
Transport::Pipe { read, stderr, .. } => match read.recv_timeout(POLL) {
Ok(message) => Ok(Some(String::from_utf8_lossy(&message).into_owned())),
Err(mpsc::RecvTimeoutError::Timeout) => Ok(None),
Err(mpsc::RecvTimeoutError::Disconnected) => {
let last = stderr
.recv_timeout(Duration::from_secs(1))
.unwrap_or_default();
Err(io::Error::other(if last.is_empty() {
"the browser closed".to_string()
} else {
format!("the browser closed: {last}")
}))
}
},
}
}
}
enum Pending {
CreateTarget,
Attach,
History,
Call(Sender<io::Result<Value>>),
Other,
}
struct Session {
transport: Transport,
next: u64,
pending: Vec<(u64, Pending)>,
session: Option<String>,
target: Option<String>,
history: Vec<i64>,
current: usize,
size: (u16, u16),
cell: (u32, u32),
waiting: Vec<Request>,
calls: Vec<Call>,
listeners: Listeners,
screencast: bool,
on_demand: bool,
scale: f64,
}
struct Wiring {
downloads: Downloads,
listeners: Listeners,
screencast: bool,
on_demand: bool,
scale: f64,
}
pub(crate) fn download_behavior(downloads: &Downloads) -> Value {
match downloads {
Downloads::Deny => json!({"behavior": "deny"}),
Downloads::Quarantine(dir) => json!({
"behavior": "allowAndName",
"downloadPath": dir.display().to_string(),
"eventsEnabled": true,
}),
}
}
impl Session {
fn send(&mut self, method: &str, params: Value, kind: Pending, page: bool) -> io::Result<()> {
self.next += 1;
let session = if page { self.session.clone() } else { None };
let text = command(self.next, method, params, session.as_deref());
self.pending.push((self.next, kind));
self.transport.send(&text)
}
fn page(&mut self, method: &str, params: Value) -> io::Result<()> {
self.send(method, params, Pending::Other, true)
}
fn call(&mut self, call: Call) -> io::Result<()> {
if self.session.is_none() {
self.calls.push(call);
return Ok(());
}
self.send(&call.method, call.params, Pending::Call(call.answer), true)
}
fn screencast(&mut self) -> io::Result<()> {
let (width, height) = viewport(self.size.0, self.size.1, self.cell);
self.page(
"Emulation.setDeviceMetricsOverride",
json!({"width": width, "height": height, "deviceScaleFactor": 1, "mobile": false}),
)?;
self.page("Page.stopScreencast", json!({}))?;
if !self.screencast {
return Ok(());
}
let (max_width, max_height) = scaled((width, height), self.scale);
self.page(
"Page.startScreencast",
json!({"format": "jpeg", "quality": 70, "maxWidth": max_width,
"maxHeight": max_height, "everyNthFrame": 1}),
)
}
fn history(&mut self) -> io::Result<()> {
self.send(
"Page.getNavigationHistory",
json!({}),
Pending::History,
true,
)
}
fn apply(&mut self, command: Request) -> io::Result<()> {
if self.session.is_none() {
self.waiting.push(command);
return Ok(());
}
match command {
Request::Open(url) => self.page("Page.navigate", json!({"url": url})),
Request::Resize(columns, rows) => {
self.size = (columns, rows);
self.screencast()
}
Request::Input(WebInput::Key(key)) => {
for event in key_events(key) {
self.page("Input.dispatchKeyEvent", event)?;
}
Ok(())
}
Request::Input(WebInput::Mouse(mouse)) => {
let event = mouse_event(mouse, self.cell);
self.page("Input.dispatchMouseEvent", event)
}
Request::Input(WebInput::Paste(text)) => {
self.page("Input.insertText", json!({"text": text}))
}
Request::Back if self.current > 0 => {
let entry = self.history[self.current - 1];
self.page("Page.navigateToHistoryEntry", json!({"entryId": entry}))
}
Request::Forward if self.current + 1 < self.history.len() => {
let entry = self.history[self.current + 1];
self.page("Page.navigateToHistoryEntry", json!({"entryId": entry}))
}
Request::Back | Request::Forward => Ok(()),
Request::Reload => self.page("Page.reload", json!({})),
Request::Screencast(on) => {
self.screencast = on;
self.screencast()
}
Request::ScreencastScale(scale) => {
self.scale = scale;
self.screencast()
}
Request::Ack(id) => self.page("Page.screencastFrameAck", json!({"sessionId": id})),
}
}
fn message(&mut self, text: &str, shared: &Mutex<Shared>) -> io::Result<()> {
let Ok(message) = serde_json::from_str::<Value>(text) else {
return Ok(());
};
if let Some(id) = message.get("id").and_then(Value::as_u64) {
let kind = self
.pending
.iter()
.position(|(n, _)| *n == id)
.map(|at| self.pending.remove(at).1)
.unwrap_or(Pending::Other);
if let Some(error) = message.get("error") {
return match kind {
Pending::Other | Pending::History => Ok(()),
Pending::Call(answer) => {
let _ = answer.send(Err(io::Error::other(error.to_string())));
Ok(())
}
_ => Err(io::Error::other(error.to_string())),
};
}
let result = &message["result"];
match kind {
Pending::CreateTarget => {
let target = result["targetId"].as_str().unwrap_or_default().to_string();
self.target = Some(target.clone());
self.send(
"Target.attachToTarget",
json!({"targetId": target, "flatten": true}),
Pending::Attach,
false,
)?;
}
Pending::Attach => {
self.session = result["sessionId"].as_str().map(str::to_string);
self.page("Page.enable", json!({}))?;
self.screencast()?;
for command in std::mem::take(&mut self.waiting) {
self.apply(command)?;
}
for call in std::mem::take(&mut self.calls) {
self.call(call)?;
}
}
Pending::Call(answer) => {
let _ = answer.send(Ok(result.clone()));
}
Pending::History => {
let entries = result["entries"].as_array().cloned().unwrap_or_default();
self.history = entries
.iter()
.filter_map(|entry| entry["id"].as_i64())
.collect();
self.current = result["currentIndex"].as_u64().unwrap_or(0) as usize;
let entry = entries.get(self.current);
{
let mut shared = lock(shared);
if let Some(entry) = entry {
if let Some(url) = entry["url"].as_str() {
shared.state.url = url.to_string();
}
shared.state.title =
entry["title"].as_str().unwrap_or_default().to_string();
}
shared.state.can_go_back = self.current > 0;
shared.state.can_go_forward = self.current + 1 < self.history.len();
}
notify(shared);
}
Pending::Other => {}
}
return Ok(());
}
let params = &message["params"];
let method = message["method"].as_str().unwrap_or_default();
let ours = match message.get("sessionId").and_then(Value::as_str) {
Some(id) => self.session.as_deref() == Some(id),
None => true,
};
if ours && !method.is_empty() {
deliver(&self.listeners, method, params);
}
match method {
"Page.screencastFrame" => {
let meta = FrameMeta::of(¶ms["metadata"]);
let ack = params["sessionId"].as_i64();
if self.on_demand {
if let (Some(data), Some(ack)) = (params["data"].as_str(), ack) {
let now = keep_frame(&mut lock(shared).raw, data.to_string(), meta, ack);
if let Some(ack) = now {
self.page("Page.screencastFrameAck", json!({"sessionId": ack}))?;
}
notify(shared);
return Ok(());
}
}
if let Some(ack) = ack {
self.page("Page.screencastFrameAck", json!({"sessionId": ack}))?;
}
if let Some(pixels) = params["data"].as_str().and_then(decode_frame) {
let mut state = lock(shared);
state.frame = Some(pixels);
state.meta = meta;
drop(state);
notify(shared);
}
}
"Page.frameStartedLoading" | "Page.frameStoppedLoading" => {
let main = params["frameId"].as_str() == self.target.as_deref();
if main {
let started = message["method"] == "Page.frameStartedLoading";
lock(shared).state.loading = started;
notify(shared);
if !started {
self.history()?;
}
}
}
"Page.frameNavigated" if params["frame"].get("parentId").is_none() => {
if let Some(url) = params["frame"]["url"].as_str() {
lock(shared).state.url = url.to_string();
notify(shared);
}
self.history()?;
}
"Page.navigatedWithinDocument" => {
if let Some(url) = params["url"].as_str() {
lock(shared).state.url = url.to_string();
notify(shared);
}
self.history()?;
}
"Inspector.detached" | "Target.detachedFromTarget" => {
return Err(io::Error::other("the page was closed"));
}
_ => {}
}
Ok(())
}
}
fn run(
connect: Connect,
stderr: ChildStderr,
commands: Receiver<Request>,
calls: Receiver<Call>,
shared: &Mutex<Shared>,
cell: (u32, u32),
wiring: Wiring,
) -> io::Result<()> {
let transport = match connect {
Connect::Port => Transport::websocket(stderr)?,
#[cfg(unix)]
Connect::Pipe(write, read) => Transport::pipe(write, read, stderr),
};
let mut session = Session {
transport,
next: 0,
pending: Vec::new(),
session: None,
target: None,
history: Vec::new(),
current: 0,
size: (80, 24),
cell,
waiting: Vec::new(),
calls: Vec::new(),
listeners: wiring.listeners,
screencast: wiring.screencast,
on_demand: wiring.on_demand,
scale: wiring.scale,
};
session.send(
"Browser.setDownloadBehavior",
download_behavior(&wiring.downloads),
Pending::Other,
false,
)?;
session.send(
"Target.createTarget",
json!({"url": "about:blank"}),
Pending::CreateTarget,
false,
)?;
let started = Instant::now();
loop {
loop {
match commands.try_recv() {
Ok(command) => session.apply(command)?,
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
let _ = session.send("Browser.close", json!({}), Pending::Other, false);
return Ok(());
}
}
}
while let Ok(call) = calls.try_recv() {
session.call(call)?;
}
if let Some(text) = session.transport.read()? {
session.message(&text, shared)?;
}
if session.session.is_none() && started.elapsed() > START_TIMEOUT {
return Err(io::Error::new(
ErrorKind::TimedOut,
"the browser did not open a page in time",
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
static STARTS: Mutex<()> = Mutex::new(());
thread_local! {
pub(super) static MADE: std::cell::RefCell<Vec<PathBuf>> =
const { std::cell::RefCell::new(Vec::new()) };
}
#[test]
fn a_chrome_that_cannot_start_leaves_no_profile_behind() {
let _starts = STARTS.lock().unwrap_or_else(|e| e.into_inner());
MADE.with(|made| made.borrow_mut().clear());
let mut engine = ChromeEngine::new().binary("/nonexistent/rich-embed-chrome");
for _ in 0..3 {
assert!(engine.start().is_err());
}
assert!(engine.profile.is_none());
let made = MADE.with(|made| made.borrow().clone());
assert_eq!(made.len(), 3, "{made:?}");
let left: Vec<_> = made.iter().filter(|dir| dir.exists()).collect();
assert!(left.is_empty(), "left behind: {left:?}");
}
#[test]
fn commands_are_devtools_json() {
let text = command(7, "Page.navigate", json!({"url": "https://a"}), Some("S1"));
let value: Value = serde_json::from_str(&text).unwrap();
assert_eq!(
value,
json!({"id": 7, "method": "Page.navigate", "params": {"url": "https://a"},
"sessionId": "S1"})
);
let browser: Value =
serde_json::from_str(&command(1, "Browser.close", json!({}), None)).unwrap();
assert!(browser.get("sessionId").is_none());
}
#[test]
fn keys_become_key_events() {
let events = key_events(Key::char('a'));
assert_eq!(
events[0],
json!({"type": "keyDown", "key": "a", "windowsVirtualKeyCode": 65, "modifiers": 0,
"text": "a", "unmodifiedText": "a"})
);
assert_eq!(events[1]["type"], "keyUp");
let enter = key_events(Key::parse("enter").unwrap());
assert_eq!(enter[0]["text"], "\r");
let up = key_events(Key::parse("ctrl+up").unwrap());
assert_eq!(
up[0],
json!({"type": "rawKeyDown", "key": "ArrowUp", "windowsVirtualKeyCode": 38,
"modifiers": 2})
);
assert!(key_events(Key::ctrl('l'))[0].get("text").is_none());
assert_eq!(
key_events(Key::parse("shift+tab").unwrap())[0]["modifiers"],
8
);
assert_eq!(
key_events(Key::parse("f5").unwrap())[0]["windowsVirtualKeyCode"],
116
);
}
#[test]
fn the_mouse_lands_in_the_middle_of_its_cell() {
let press = mouse_event(Mouse::new(MouseKind::Down(Button::Left), 2, 1), (10, 20));
assert_eq!(
press,
json!({"type": "mousePressed", "x": 25.0, "y": 30.0, "button": "left",
"clickCount": 1, "modifiers": 0})
);
let wheel = mouse_event(Mouse::new(MouseKind::ScrollDown, 0, 0), (10, 20));
assert_eq!(wheel["type"], "mouseWheel");
assert_eq!(wheel["deltaY"], 60.0);
let moved = mouse_event(Mouse::new(MouseKind::Moved, 0, 0), (10, 20));
assert_eq!(moved["button"], "none");
}
#[test]
fn the_browser_is_launched_sandboxed_in_its_own_profile() {
let extra = vec!["--no-sandbox".to_string(), "--lang=en".to_string()];
let args = launch_args(
Path::new("/tmp/profile"),
&extra,
80,
24,
(10, 20),
Wire::DEFAULT,
false,
);
assert!(args.contains(&"--headless=new".to_string()));
assert!(args.contains(&"--user-data-dir=/tmp/profile".to_string()));
assert!(args.contains(&"--window-size=800,480".to_string()));
assert!(args.contains(&"--lang=en".to_string()));
assert!(!args.iter().any(|arg| arg.contains("no-sandbox")));
assert_eq!(args.last().unwrap(), "about:blank");
let engine = ChromeEngine::new().args(["--no-sandbox"]);
assert!(engine.args.is_empty());
}
#[test]
fn devtools_runs_over_a_pipe_on_unix_and_a_port_elsewhere() {
let args = launch_args(Path::new("/p"), &[], 80, 24, (10, 20), Wire::DEFAULT, false);
let pipe = args.iter().any(|arg| arg == "--remote-debugging-pipe");
let port = args.iter().any(|arg| arg == "--remote-debugging-port=0");
assert_eq!((pipe, port), (cfg!(unix), !cfg!(unix)), "{args:?}");
assert_eq!(ChromeEngine::new().wire, Wire::DEFAULT);
}
#[test]
fn a_devtools_switch_of_the_callers_is_dropped() {
let extra: Vec<String> = [
"--remote-debugging-port=9222",
"-Remote-Debugging-Address=0.0.0.0",
"--remote-debugging-pipe",
"--lang=en",
"https://remote-debugging-port.example",
]
.map(String::from)
.to_vec();
let args = launch_args(
Path::new("/p"),
&extra,
80,
24,
(10, 20),
Wire::DEFAULT,
false,
);
let devtools: Vec<_> = args
.iter()
.filter(|arg| arg.to_ascii_lowercase().starts_with("--remote-debugging"))
.collect();
assert_eq!(devtools, [Wire::DEFAULT.switch()]);
let engine = ChromeEngine::new().args(extra);
assert_eq!(
engine.args,
["--lang=en", "https://remote-debugging-port.example"]
);
}
fn a_frame_arrives_over(wire: Wire) {
let Some(binary) = std::env::var_os("RICH_EMBED_CHROME") else {
eprintln!("skipped: set RICH_EMBED_CHROME to a Chrome or Chromium binary to run");
return;
};
let mut engine = ChromeEngine::new().binary(binary);
engine.wire = wire;
engine.resize(8, 4).unwrap();
engine
.open("data:text/html,<body style='background:%2300ff00'>")
.unwrap();
let green = |frame: &Option<WebFrame>| match frame {
Some(WebFrame::Pixels(p)) => {
let at = (p.width as usize * p.height as usize / 2 + p.width as usize / 2) * 3;
(p.width, p.height) == (80, 80)
&& p.rgb
.get(at..at + 3)
.is_some_and(|c| c[0] < 60 && c[1] > 200 && c[2] < 60)
}
_ => false,
};
let start = Instant::now();
let mut frame = None;
while !green(&frame) && start.elapsed() < Duration::from_secs(60) {
frame = engine.poll().or(frame);
thread::sleep(Duration::from_millis(20));
}
assert!(
green(&frame),
"no green frame over {wire:?}: {:?}",
engine.state().error
);
}
#[cfg(unix)]
#[test]
fn a_frame_arrives_over_the_pipe() {
a_frame_arrives_over(Wire::Pipe);
}
#[test]
fn a_frame_arrives_over_the_port() {
a_frame_arrives_over(Wire::Port);
}
#[test]
fn on_demand_frames_come_as_they_are_taken() {
let Some(binary) = std::env::var_os("RICH_EMBED_CHROME") else {
eprintln!("skipped: set RICH_EMBED_CHROME to a Chrome or Chromium binary to run");
return;
};
let mut engine = ChromeEngine::new()
.binary(binary)
.screencast_on_demand(true);
engine.resize(8, 4).unwrap();
engine
.open(
"data:text/html,<body style='height:2000px;margin:0'><script> scrollTo(0,40);let n=0;(function f(){document.body.style.background= 'rgb('+(n++%25256)+',0,0)';requestAnimationFrame(f)})()</script>",
)
.unwrap();
let start = Instant::now();
let mut taken = Vec::new();
while taken.len() < 4 && start.elapsed() < Duration::from_secs(60) {
if let Some(frame) = engine.take_screencast() {
taken.push(frame);
}
thread::sleep(Duration::from_millis(30));
}
assert_eq!(taken.len(), 4, "{:?}", engine.state().error);
let last = taken.last().unwrap();
assert_eq!((last.pixels.width, last.pixels.height), (80, 80));
assert_eq!(last.meta.scroll_y, 40.0);
}
#[test]
fn on_demand_frames_stay_at_most_one_ahead() {
let meta = FrameMeta::default();
let mut waiting = None;
assert_eq!(keep_frame(&mut waiting, "a".into(), meta, 1), Some(1));
assert_eq!(keep_frame(&mut waiting, "b".into(), meta, 2), None);
let (data, _, ack) = waiting.take().unwrap();
assert_eq!((data.as_str(), ack), ("b", Some(2)));
assert_eq!(keep_frame(&mut waiting, "c".into(), meta, 3), Some(3));
assert_eq!(waiting.unwrap().2, None);
}
#[test]
fn a_scaled_screencast_asks_for_fewer_pixels() {
assert_eq!(scaled((800, 720), 1.0), (800, 720));
assert_eq!(scaled((800, 720), 0.125), (100, 90));
assert_eq!(scaled((800, 720), 0.001), (1, 1));
let frame = ScreencastFrame {
pixels: Pixels::new(100, 90, vec![0; 100 * 90 * 3]).unwrap(),
meta: FrameMeta {
page_scale: 1.0,
device_width: 800.0,
device_height: 720.0,
..FrameMeta::default()
},
};
let cut = frame.crop(80.0, 160.0, 480.0, 270.0).unwrap();
assert_eq!((cut.width, cut.height), (60, 34));
}
#[test]
fn every_spelling_of_a_sandbox_switch_is_dropped() {
for arg in [
"--no-sandbox",
"-no-sandbox",
"---no-sandbox",
"--No-Sandbox",
"--no-sandbox=1",
"--disable-setuid-sandbox",
"--disable-namespace-sandbox",
"-disable-seccomp-filter-sandbox",
"--disable-gpu-sandbox",
"--no-zygote-sandbox",
"--no-sandbox-and-elevated",
"--single-process",
"--no-zygote",
] {
assert!(turns_sandbox_off(arg), "{arg}");
}
assert_eq!(turns_sandbox_off("/no-sandbox"), cfg!(windows));
for arg in [
"--lang=en",
"--enable-sandbox",
"https://no-sandbox.example",
"--",
] {
assert!(!turns_sandbox_off(arg), "{arg}");
}
let extra: Vec<String> = ["-no-sandbox", "--Disable-Setuid-Sandbox", "--lang=en"]
.map(String::from)
.to_vec();
let args = launch_args(
Path::new("/tmp/profile"),
&extra,
80,
24,
(10, 20),
Wire::DEFAULT,
false,
);
assert!(
!args
.iter()
.any(|arg| arg.to_ascii_lowercase().contains("sandbox"))
);
let engine = ChromeEngine::new().args(extra);
assert_eq!(engine.args, ["--lang=en"]);
}
#[cfg(unix)]
#[test]
fn the_profile_is_this_users_only() {
use std::os::unix::fs::PermissionsExt;
let _starts = STARTS.lock().unwrap_or_else(|e| e.into_inner());
let profile = temporary_profile().unwrap();
let mode = std::fs::metadata(&profile).unwrap().permissions().mode();
std::fs::remove_dir(&profile).unwrap();
assert_eq!(mode & 0o777, 0o700, "{mode:o}");
}
#[test]
fn the_devtools_address_is_read_from_standard_error() {
assert_eq!(
devtools_url("DevTools listening on ws://127.0.0.1:41235/devtools/browser/ab-cd"),
Some("ws://127.0.0.1:41235/devtools/browser/ab-cd".to_string())
);
assert_eq!(devtools_url("[1:2:ERROR] something else"), None);
}
#[test]
fn screencast_frames_crop_by_their_scroll_and_scale() {
let rgb = (0..16u8).flat_map(|i| [i, 0, 0]).collect();
let frame = ScreencastFrame {
pixels: Pixels::new(4, 4, rgb).unwrap(),
meta: FrameMeta {
scroll_y: 100.0,
page_scale: 1.0,
device_width: 8.0,
device_height: 8.0,
..FrameMeta::default()
},
};
let part = frame.crop(2.0, 102.0, 4.0, 4.0).unwrap();
assert_eq!((part.width, part.height), (2, 2));
assert_eq!(part.rgb, vec![5, 0, 0, 6, 0, 0, 9, 0, 0, 10, 0, 0]);
assert!(frame.crop(0.0, 0.0, 8.0, 50.0).is_none());
assert_eq!(frame.crop(0.0, 98.0, 8.0, 4.0).unwrap().height, 1);
let meta = FrameMeta::of(&json!({"scrollOffsetY": 7.5, "pageScaleFactor": 2,
"deviceWidth": 800, "timestamp": 1.5}));
assert_eq!(
(meta.scroll_y, meta.page_scale, meta.timestamp),
(7.5, 2.0, Some(1.5))
);
}
#[test]
fn frames_decode_from_base64() {
assert_eq!(base64_decode("aGk=").unwrap(), b"hi");
assert_eq!(base64_decode("aGVsbG8gd29ybGQ=").unwrap(), b"hello world");
assert_eq!(base64_decode("not base64!"), None);
let mut png = Vec::new();
image::RgbImage::from_pixel(1, 1, image::Rgb([255, 0, 0]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let encoded = encode(&png);
let pixels = decode_frame(&encoded).unwrap();
assert_eq!(
(pixels.width, pixels.height, pixels.rgb),
(1, 1, vec![255, 0, 0])
);
}
fn encode(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = chunk.iter().fold(0u32, |n, &b| n << 8 | b as u32) << (8 * (3 - chunk.len()));
for i in 0..=chunk.len() {
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
}
for _ in chunk.len()..3 {
out.push('=');
}
}
out
}
#[test]
fn the_browser_is_muted_unless_audio_is_asked_for() {
let muted = launch_args(Path::new("/p"), &[], 80, 24, (10, 20), Wire::DEFAULT, false);
assert!(muted.contains(&"--mute-audio".to_string()));
let loud = launch_args(Path::new("/p"), &[], 80, 24, (10, 20), Wire::DEFAULT, true);
assert!(!loud.iter().any(|arg| arg.contains("mute")), "{loud:?}");
assert!(!ChromeEngine::new().audio);
assert!(ChromeEngine::new().audio(true).audio);
}
#[test]
fn downloads_are_denied_unless_a_quarantine_is_given() {
assert_eq!(ChromeEngine::new().downloads, Downloads::Deny);
assert_eq!(
download_behavior(&Downloads::Deny),
json!({"behavior": "deny"})
);
let kept = download_behavior(&Downloads::Quarantine(PathBuf::from("/q")));
assert_eq!(kept["behavior"], "allowAndName");
assert_eq!(kept["downloadPath"], "/q");
assert_eq!(kept["eventsEnabled"], true);
}
#[test]
fn listeners_get_only_the_methods_they_asked_for() {
let listeners: Listeners = Arc::default();
let devtools = DevTools {
calls: mpsc::channel().0,
listeners: Arc::clone(&listeners),
};
let pings = devtools.listen(&["Runtime.bindingCalled"]);
let gone = devtools.listen(&["Runtime.bindingCalled"]);
drop(gone);
deliver(&listeners, "Page.loadEventFired", &json!({}));
deliver(&listeners, "Runtime.bindingCalled", &json!({"name": "x"}));
assert_eq!(
pings.try_recv().unwrap(),
DevToolsEvent {
method: "Runtime.bindingCalled".into(),
params: json!({"name": "x"})
}
);
assert!(pings.try_recv().is_err());
assert_eq!(listeners.lock().unwrap().len(), 1);
}
#[test]
fn a_kept_profile_is_made_private_and_never_removed() {
let _starts = STARTS.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("rich-embed-kept-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut engine = ChromeEngine::new()
.binary("/nonexistent/rich-embed-chrome")
.profile(&dir);
assert!(engine.start().is_err());
drop(engine);
assert!(dir.is_dir(), "a kept profile stays");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "{mode:o}");
}
std::fs::remove_dir_all(&dir).unwrap();
}
fn download_with(downloads: Downloads) -> Option<(DevToolsEvent, DevToolsEvent)> {
let binary = std::env::var_os("RICH_EMBED_CHROME")?;
let _starts = STARTS.lock().unwrap_or_else(|e| e.into_inner());
let mut engine = ChromeEngine::new().binary(binary).downloads(downloads);
engine.resize(20, 10).unwrap();
let devtools = engine.devtools().unwrap();
let begun = devtools.listen(&["Browser.downloadWillBegin", "Browser.downloadProgress"]);
engine
.open("data:text/html,<a id=d download=evil.sh href='data:text/plain,hi'>x</a>")
.unwrap();
let start = Instant::now();
while start.elapsed() < Duration::from_secs(30) {
let clicked = devtools.call(
"Runtime.evaluate",
json!({"expression": "document.getElementById('d') && (d.click(), 1)"}),
Duration::from_secs(10),
);
if clicked.is_ok_and(|r| r["result"]["value"] == 1) {
break;
}
thread::sleep(Duration::from_millis(50));
}
let first = begun.recv_timeout(Duration::from_secs(5)).ok()?;
while let Ok(event) = begun.recv_timeout(Duration::from_secs(10)) {
if event.params["state"] == "completed" {
return Some((first, event));
}
}
let unfinished = DevToolsEvent {
method: String::new(),
params: json!({}),
};
Some((first, unfinished))
}
#[test]
fn a_quarantined_download_is_announced_and_kept_under_its_id() {
if std::env::var_os("RICH_EMBED_CHROME").is_none() {
eprintln!("skipped: set RICH_EMBED_CHROME to a Chrome or Chromium binary to run");
return;
}
let dir = std::env::temp_dir().join(format!("rich-embed-q-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (event, done) = download_with(Downloads::Quarantine(dir.clone())).expect("no download");
assert_eq!(event.method, "Browser.downloadWillBegin");
assert_eq!(event.params["suggestedFilename"], "evil.sh");
let guid = event.params["guid"].as_str().unwrap();
assert_eq!(done.params["state"], "completed", "{done:?}");
assert!(dir.join(guid).is_file(), "not kept under its id");
assert!(!dir.join("evil.sh").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_denied_download_never_begins() {
if std::env::var_os("RICH_EMBED_CHROME").is_none() {
eprintln!("skipped: set RICH_EMBED_CHROME to a Chrome or Chromium binary to run");
return;
}
assert!(download_with(Downloads::Deny).is_none());
}
#[test]
fn a_missing_browser_is_an_error_not_a_hang() {
let _starts = STARTS.lock().unwrap_or_else(|e| e.into_inner());
let mut engine = ChromeEngine::new().binary("/nonexistent/chrome-for-a-test");
assert!(engine.resize(80, 24).is_err());
}
}