use std::path::PathBuf;
use anyhow::Context as _;
use base64::Engine as _;
use ff_rdp_core::{Grip, LongStringActor, ScreenshotActor, ScreenshotContentActor};
use serde_json::json;
use crate::cli::args::Cli;
use crate::error::AppError;
use crate::hints::{HintContext, HintSource};
use crate::output;
use crate::output_pipeline::OutputPipeline;
use super::connect_tab::connect_direct;
use super::js_helpers::eval_or_bail;
fn build_screenshot_js(height_override: Option<HeightOverride>) -> String {
let height_expr = match height_override {
None => "window.innerHeight || document.documentElement.clientHeight || 600".to_owned(),
Some(HeightOverride::FullPage) => {
"Math.max(\
document.documentElement.scrollHeight,\
document.body ? document.body.scrollHeight : 0,\
(document.scrollingElement && document.scrollingElement.scrollHeight) || 0,\
window.innerHeight || 0\
)"
.to_owned()
}
Some(HeightOverride::Explicit(n)) => n.to_string(),
};
format!(
"(function() {{\n var w = window.innerWidth || document.documentElement.clientWidth || 800;\n var h = {height_expr};\n var canvas = document.createElement('canvas');\n canvas.width = w;\n canvas.height = h;\n var ctx = canvas.getContext('2d');\n if (!ctx || typeof ctx.drawWindow !== 'function') {{ return null; }}\n ctx.drawWindow(window, 0, 0, w, h, 'rgb(255,255,255)');\n return canvas.toDataURL('image/png');\n}})()"
)
}
#[derive(Copy, Clone, Debug)]
enum HeightOverride {
FullPage,
Explicit(u32),
}
pub(crate) struct ScreenshotOpts<'a> {
pub(crate) output_path: Option<&'a str>,
pub(crate) base64_mode: bool,
pub(crate) full_page: bool,
pub(crate) viewport_height: Option<u32>,
}
const PNG_DATA_URL_PREFIX: &str = "data:image/png;base64,";
pub fn run(cli: &Cli, opts: &ScreenshotOpts<'_>) -> Result<(), AppError> {
let height_override = match (opts.full_page, opts.viewport_height) {
(true, Some(_)) => {
return Err(AppError::User(
"screenshot: --full-page and --viewport-height are mutually exclusive".to_owned(),
));
}
(true, None) => Some(HeightOverride::FullPage),
(false, Some(n)) => Some(HeightOverride::Explicit(n)),
(false, None) => None,
};
let output_path = opts.output_path;
let base64_mode = opts.base64_mode;
let screenshot_js = build_screenshot_js(height_override);
let screenshot_js = screenshot_js.as_str();
let mut ctx = connect_direct(cli)?;
let console_actor = ctx.target.console_actor.clone();
let eval_result = eval_or_bail(
&mut ctx,
&console_actor,
screenshot_js,
"screenshot JS threw an exception",
)?;
let data_url = if let Some(url) = resolve_string(&mut ctx, &eval_result.result)? {
url
} else {
let sc_actor = ctx.target.screenshot_content_actor.clone();
let browsing_ctx_id = ctx.target.browsing_context_id;
if let Some(actor) = sc_actor {
match ScreenshotContentActor::capture(
ctx.transport_mut(),
actor.as_ref(),
height_override.is_some_and(|h| matches!(h, HeightOverride::FullPage)),
) {
Ok(capture) => capture.data,
Err(legacy_err) if legacy_err.is_unrecognized_packet_type() => {
try_two_step_screenshot(
&mut ctx,
&actor,
browsing_ctx_id,
height_override.is_some_and(|h| matches!(h, HeightOverride::FullPage)),
)?
}
Err(e) => {
return Err(AppError::User(format!(
"screenshot: screenshotContentActor capture failed ({e}) — \
screenshots require headless mode; relaunch with: ff-rdp launch --headless"
)));
}
}
} else {
return Err(AppError::User(
"screenshot: drawWindow not available and no screenshotContentActor found — \
screenshots require headless mode; relaunch with: ff-rdp launch --headless"
.to_owned(),
));
}
};
let b64 = data_url.strip_prefix(PNG_DATA_URL_PREFIX).ok_or_else(|| {
AppError::User(format!(
"screenshot: unexpected data URL format (expected prefix '{PNG_DATA_URL_PREFIX}')"
))
})?;
let png_bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| AppError::from(anyhow::anyhow!("screenshot: base64 decode failed: {e}")))?;
let (width, height) = png_dimensions(&png_bytes).unwrap_or((0, 0));
let results = if base64_mode {
json!({
"base64": b64,
"width": width,
"height": height,
"bytes": png_bytes.len(),
})
} else {
let dest = resolve_output_path(output_path)
.map_err(|e| AppError::from(anyhow::anyhow!("screenshot: {e}")))?;
std::fs::write(&dest, &png_bytes)
.with_context(|| format!("screenshot: could not write to '{}'", dest.display()))
.map_err(AppError::from)?;
let abs_path = dest
.canonicalize()
.unwrap_or(dest)
.to_string_lossy()
.into_owned();
json!({
"path": abs_path,
"width": width,
"height": height,
"bytes": png_bytes.len(),
})
};
let meta = json!({"host": cli.host, "port": cli.port});
let envelope = output::envelope(&results, 1, &meta);
let hint_ctx = HintContext::new(HintSource::Screenshot);
OutputPipeline::from_cli(cli)?
.finalize_with_hints(&envelope, Some(&hint_ctx))
.map_err(AppError::from)
}
fn try_two_step_screenshot(
ctx: &mut super::connect_tab::ConnectedTab,
sc_actor: &ff_rdp_core::ActorId,
browsing_ctx_id: Option<u64>,
full_page: bool,
) -> Result<String, AppError> {
let browsing_ctx_id = browsing_ctx_id.ok_or_else(|| {
AppError::User(
"screenshot: Firefox 149+ screenshot requires a browsing context ID \
which was not found in the target response. \
Try upgrading ff-rdp or filing a bug with your Firefox version."
.to_owned(),
)
})?;
let prep =
ScreenshotContentActor::prepare_capture(ctx.transport_mut(), sc_actor.as_ref(), full_page)
.map_err(|e| {
AppError::User(format!(
"screenshot: screenshotContentActor.prepareCapture failed ({e})"
))
})?;
let screenshot_actor = ScreenshotActor::get_actor_id(ctx.transport_mut()).map_err(|e| {
AppError::User(format!(
"screenshot: could not find root screenshotActor ({e}) — \
this Firefox version may not support the two-step screenshot protocol"
))
})?;
let data = ScreenshotActor::capture(
ctx.transport_mut(),
&screenshot_actor,
browsing_ctx_id,
full_page,
&prep,
)
.map_err(|e| {
AppError::User(format!(
"screenshot: screenshotActor.capture failed ({e}) — \
screenshots require headless mode; relaunch with: ff-rdp launch --headless"
))
})?;
Ok(data)
}
fn resolve_string(
ctx: &mut super::connect_tab::ConnectedTab,
grip: &Grip,
) -> Result<Option<String>, AppError> {
match grip {
Grip::Null | Grip::Undefined => Ok(None),
Grip::Value(serde_json::Value::String(s)) => Ok(Some(s.clone())),
Grip::LongString {
actor,
length,
initial: _,
} => {
let full = LongStringActor::full_string(ctx.transport_mut(), actor.as_ref(), *length)
.map_err(AppError::from)?;
Ok(Some(full))
}
other => Err(AppError::User(format!(
"screenshot: unexpected result type: {}",
other.to_json()
))),
}
}
fn resolve_output_path(output_path: Option<&str>) -> anyhow::Result<PathBuf> {
if let Some(p) = output_path {
return Ok(PathBuf::from(p));
}
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.context("system clock is before UNIX epoch")?
.as_millis();
Ok(PathBuf::from(format!("screenshot-{ts}.png")))
}
fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
if data.len() < 24 {
return None;
}
let width = u32::from_be_bytes(data[16..20].try_into().ok()?);
let height = u32::from_be_bytes(data[20..24].try_into().ok()?);
Some((width, height))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::args::{Cli, Command};
use clap::Parser as _;
#[test]
fn clap_screenshot_full_page_flag_parsed() {
let cli = Cli::try_parse_from(["ff-rdp", "screenshot", "--full-page"])
.expect("should parse --full-page");
let Command::Screenshot { full_page, .. } = cli.command else {
panic!("expected Screenshot command");
};
assert!(full_page, "--full-page flag must be set");
}
#[test]
fn clap_a11y_limit_and_format_text_parsed() {
let cli = Cli::try_parse_from(["ff-rdp", "a11y", "--limit", "5", "--format", "text"])
.expect("should parse a11y --limit 5 --format text");
assert_eq!(cli.limit, Some(5));
assert_eq!(cli.format, "text");
assert!(matches!(cli.command, Command::A11y { .. }));
}
#[test]
fn png_dimensions_minimal_png() {
let b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC";
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.unwrap();
let (w, h) = png_dimensions(&bytes).unwrap();
assert_eq!(w, 1);
assert_eq!(h, 1);
}
#[test]
fn png_dimensions_too_short() {
assert!(png_dimensions(&[0u8; 10]).is_none());
}
#[test]
fn resolve_output_path_explicit() {
let path = resolve_output_path(Some("/tmp/test.png")).unwrap();
assert_eq!(path, PathBuf::from("/tmp/test.png"));
}
#[test]
fn resolve_output_path_auto_timestamped() {
let path = resolve_output_path(None).unwrap();
let name = path.to_string_lossy();
assert!(
name.starts_with("screenshot-")
&& path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("png")),
"unexpected auto path: {name}"
);
}
#[test]
fn strip_data_url_prefix() {
let url = format!("{PNG_DATA_URL_PREFIX}abc123");
let b64 = url.strip_prefix(PNG_DATA_URL_PREFIX);
assert_eq!(b64, Some("abc123"));
}
#[test]
fn strip_data_url_prefix_mismatch() {
let url = "data:image/jpeg;base64,abc";
assert!(url.strip_prefix(PNG_DATA_URL_PREFIX).is_none());
}
#[test]
fn build_js_default_uses_inner_height() {
let js = build_screenshot_js(None);
assert!(js.contains("window.innerHeight"));
assert!(!js.contains("scrollHeight"));
}
#[test]
fn build_js_full_page_uses_scroll_height() {
let js = build_screenshot_js(Some(HeightOverride::FullPage));
assert!(js.contains("scrollHeight"));
assert!(js.contains("scrollingElement"));
}
#[test]
fn build_js_explicit_height_inlines_value() {
let js = build_screenshot_js(Some(HeightOverride::Explicit(4321)));
assert!(
js.contains("var h = 4321;"),
"expected explicit height to be inlined, got: {js}"
);
}
}