use std::io::Write;
use std::net::TcpStream;
use std::time::Duration;
pub const DEFAULT_PORT: u16 = 9229;
#[derive(Debug, thiserror::Error)]
pub enum DevtoolsError {
#[error("Приложение не запущено. Запустите light dev")]
NotRunning,
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
BadArgs(String),
}
pub type Result<T> = std::io::Result<T>;
#[derive(Debug, Clone)]
pub struct DevtoolsRequest {
pub cmd: String,
pub body: Option<String>,
pub save_path: Option<std::path::PathBuf>,
pub json: bool,
}
impl DevtoolsRequest {
pub fn to_wire(&self) -> String {
let name = self.cmd.to_ascii_uppercase();
let name = if name == "STATE" {
"SNAPSHOT".to_owned()
} else {
name
};
match &self.body {
Some(b) => format!("{name} {b}"),
None => name,
}
}
}
pub fn send_request(
port: u16,
req: &DevtoolsRequest,
) -> std::result::Result<String, DevtoolsError> {
let addr = format!("127.0.0.1:{port}");
let socket: std::net::SocketAddr = addr.parse().map_err(|_| DevtoolsError::NotRunning)?;
let stream = TcpStream::connect_timeout(&socket, Duration::from_secs(3))
.map_err(|_| DevtoolsError::NotRunning)?;
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
let mut buf_reader = std::io::BufReader::new(stream.try_clone()?);
let mut writer = stream;
let wire = req.to_wire();
writer.write_all(wire.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
let mut line = String::new();
use std::io::Read;
let mut byte = [0u8; 1];
loop {
if buf_reader.read(&mut byte[..1])? == 0 {
break;
}
if byte[0] == b'\n' {
break;
}
if byte[0] == b'\r' {
continue;
}
line.push(byte[0] as char);
}
Ok(line)
}
fn resolve_png_path(path: &std::path::Path) -> std::path::PathBuf {
if path.is_dir() {
let ts = format_unix_now();
path.join(format!("{ts}.png"))
} else if path.extension().is_none() {
let base = path
.file_name()
.and_then(|n| n.to_str())
.filter(|n| !n.is_empty() && *n != ".")
.map(String::from)
.unwrap_or_else(|| "screenshot".to_owned());
path.join(format!("{base}_{}.png", format_unix_now()))
} else {
path.to_path_buf()
}
}
fn format_unix_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86400) as i64;
let sod = (secs % 86400) as u32;
let (y, mo, day) = civil_from_days_local(days);
format!(
"{y:04}{mo:02}{day:02}_{:02}{:02}{:02}",
sod / 3600,
(sod % 3600) / 60,
sod % 60
)
}
fn civil_from_days_local(z: i64) -> (i64, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let mo = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if mo <= 2 { y + 1 } else { y };
(y, mo, d)
}
pub fn is_screenshot_response(raw: &str) -> bool {
raw.starts_with("base64:")
}
pub fn shot_error_message(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed == "unknown command" {
"Сервер не поддерживает команду SHOT (возможно, устаревшая версия приложения)".to_owned()
} else if trimmed.starts_with("error:") || trimmed == "timeout" {
format!("Ошибка от сервера DevTools: {trimmed}")
} else {
format!("Ожидался ответ `base64:<png>`, получен: `{trimmed}`")
}
}
pub fn save_png(
base64_payload: &str,
path: &std::path::Path,
) -> std::io::Result<std::path::PathBuf> {
use base64::Engine;
if !is_screenshot_response(base64_payload) {
return Err(std::io::Error::other(shot_error_message(base64_payload)));
}
let target = resolve_png_path(path);
let b64 = base64_payload
.strip_prefix("base64:")
.unwrap_or(base64_payload);
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| std::io::Error::other(format!("base64: {e}")))?;
if let Some(parent) = target.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target, &bytes)?;
Ok(target)
}
pub fn parse_attr(s: &str) -> std::result::Result<(String, String), DevtoolsError> {
let (k, v) = s
.split_once('=')
.ok_or_else(|| DevtoolsError::BadArgs(format!("--attr expects k=v, got `{s}`")))?;
Ok((k.to_owned(), v.to_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
fn req(cmd: &str, body: Option<&str>) -> DevtoolsRequest {
DevtoolsRequest {
cmd: cmd.to_owned(),
body: body.map(|s| s.to_owned()),
save_path: None,
json: false,
}
}
#[test]
fn to_wire_simple() {
assert_eq!(req("tree", None).to_wire(), "TREE");
assert_eq!(req("layout", None).to_wire(), "LAYOUT");
assert_eq!(req("shot", None).to_wire(), "SHOT");
}
#[test]
fn to_wire_with_body() {
assert_eq!(
req("find", Some(r#"{"tag":"button","text":"+1"}"#)).to_wire(),
"FIND {\"tag\":\"button\",\"text\":\"+1\"}"
);
assert_eq!(
req("type", Some("0.1 hello world")).to_wire(),
"TYPE 0.1 hello world"
);
assert_eq!(req("key", Some("0.1 Enter")).to_wire(), "KEY 0.1 Enter");
assert_eq!(req("click", Some("0.0")).to_wire(), "CLICK 0.0");
}
#[test]
fn to_wire_state_is_snapshot() {
assert_eq!(req("state", None).to_wire(), "SNAPSHOT");
}
#[test]
fn parse_attr_roundtrip() {
assert_eq!(
parse_attr("class=btn").expect("k=v"),
("class".to_owned(), "btn".to_owned())
);
assert!(parse_attr("no-equals").is_err());
}
#[test]
fn shot_error_detection() {
assert!(is_screenshot_response("base64:AAAA"));
assert!(!is_screenshot_response("unknown command"));
assert!(!is_screenshot_response("timeout"));
assert!(!is_screenshot_response("error: no frame"));
let m = shot_error_message("unknown command");
assert!(m.contains("SHOT"), "{m}");
assert!(m.contains("устаревша"), "{m}");
}
#[test]
fn resolve_png_path_dir_gets_timestamped_name() {
let base = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let dir = base.join(format!("_resolve_dir_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create");
let p = resolve_png_path(&dir);
assert!(p.starts_with(&dir), "{}", p.display());
assert!(
p.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.ends_with(".png")
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_png_path_no_extension() {
let p = resolve_png_path(std::path::Path::new("shots/shots"));
assert!(
p.starts_with(std::path::Path::new("shots/shots")),
"{}",
p.display()
);
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
assert!(name.ends_with(".png"), "{name}");
assert!(name.starts_with("shots_"), "{name}");
}
#[test]
fn resolve_png_path_with_png_ext() {
let p = resolve_png_path(std::path::Path::new("out/frame.png"));
assert_eq!(p, std::path::PathBuf::from("out/frame.png"));
}
#[test]
fn save_png_writes_file_and_rejects_unknown_command() {
use base64::Engine;
let dir = std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
.join(format!("_save_png_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create dir");
let png_bytes: Vec<u8> = vec![137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4];
let b64 = format!(
"base64:{}",
base64::engine::general_purpose::STANDARD.encode(&png_bytes)
);
let p = save_png(&b64, &dir).expect("save");
assert!(p.is_file(), "{} должен существовать", p.display());
assert_eq!(std::fs::read(&p).unwrap(), png_bytes);
let err = save_png("unknown command", &dir).expect_err("должен быть error");
assert!(err.to_string().contains("SHOT"), "{}", err);
let _ = std::fs::remove_dir_all(&dir);
}
}