1use std::io::Write;
9use std::net::TcpStream;
10use std::time::Duration;
11
12pub const DEFAULT_PORT: u16 = 9229;
14
15#[derive(Debug, thiserror::Error)]
17pub enum DevtoolsError {
18 #[error("Приложение не запущено. Запустите light dev")]
20 NotRunning,
21 #[error("io: {0}")]
23 Io(#[from] std::io::Error),
24 #[error("{0}")]
26 BadArgs(String),
27}
28
29pub type Result<T> = std::io::Result<T>;
31
32#[derive(Debug, Clone)]
34pub struct DevtoolsRequest {
35 pub cmd: String,
38 pub body: Option<String>,
40 pub save_path: Option<std::path::PathBuf>,
42 pub json: bool,
44}
45
46impl DevtoolsRequest {
47 pub fn to_wire(&self) -> String {
51 let name = self.cmd.to_ascii_uppercase();
52 let name = if name == "STATE" {
54 "SNAPSHOT".to_owned()
55 } else {
56 name
57 };
58 match &self.body {
59 Some(b) => format!("{name} {b}"),
60 None => name,
61 }
62 }
63}
64
65pub fn send_request(
69 port: u16,
70 req: &DevtoolsRequest,
71) -> std::result::Result<String, DevtoolsError> {
72 let addr = format!("127.0.0.1:{port}");
73 let socket: std::net::SocketAddr = addr.parse().map_err(|_| DevtoolsError::NotRunning)?;
74 let stream = TcpStream::connect_timeout(&socket, Duration::from_secs(3))
75 .map_err(|_| DevtoolsError::NotRunning)?;
76 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
77 let mut buf_reader = std::io::BufReader::new(stream.try_clone()?);
78 let mut writer = stream;
79 let wire = req.to_wire();
80 writer.write_all(wire.as_bytes())?;
81 writer.write_all(b"\n")?;
82 writer.flush()?;
83 let mut line = String::new();
84 use std::io::Read;
88 let mut byte = [0u8; 1];
89 loop {
90 if buf_reader.read(&mut byte[..1])? == 0 {
91 break;
92 }
93 if byte[0] == b'\n' {
94 break;
95 }
96 if byte[0] == b'\r' {
97 continue;
98 }
99 line.push(byte[0] as char);
100 }
101 Ok(line)
102}
103
104fn resolve_png_path(path: &std::path::Path) -> std::path::PathBuf {
107 if path.is_dir() {
108 let ts = format_unix_now();
109 path.join(format!("{ts}.png"))
110 } else if path.extension().is_none() {
111 let base = path
114 .file_name()
115 .and_then(|n| n.to_str())
116 .filter(|n| !n.is_empty() && *n != ".")
117 .map(String::from)
118 .unwrap_or_else(|| "screenshot".to_owned());
119 path.join(format!("{base}_{}.png", format_unix_now()))
120 } else {
121 path.to_path_buf()
122 }
123}
124
125fn format_unix_now() -> String {
127 use std::time::{SystemTime, UNIX_EPOCH};
128 let secs = SystemTime::now()
129 .duration_since(UNIX_EPOCH)
130 .map(|d| d.as_secs())
131 .unwrap_or(0);
132 let days = (secs / 86400) as i64;
133 let sod = (secs % 86400) as u32;
134 let (y, mo, day) = civil_from_days_local(days);
135 format!(
136 "{y:04}{mo:02}{day:02}_{:02}{:02}{:02}",
137 sod / 3600,
138 (sod % 3600) / 60,
139 sod % 60
140 )
141}
142
143fn civil_from_days_local(z: i64) -> (i64, u32, u32) {
146 let z = z + 719468;
147 let era = if z >= 0 { z } else { z - 146096 } / 146097;
148 let doe = (z - era * 146097) as u64;
149 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
150 let y = yoe as i64 + era * 400;
151 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
152 let mp = (5 * doy + 2) / 153;
153 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
154 let mo = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
155 let y = if mo <= 2 { y + 1 } else { y };
156 (y, mo, d)
157}
158
159pub fn is_screenshot_response(raw: &str) -> bool {
163 raw.starts_with("base64:")
164}
165
166pub fn shot_error_message(raw: &str) -> String {
169 let trimmed = raw.trim();
170 if trimmed == "unknown command" {
171 "Сервер не поддерживает команду SHOT (возможно, устаревшая версия приложения)".to_owned()
172 } else if trimmed.starts_with("error:") || trimmed == "timeout" {
173 format!("Ошибка от сервера DevTools: {trimmed}")
174 } else {
175 format!("Ожидался ответ `base64:<png>`, получен: `{trimmed}`")
176 }
177}
178
179pub fn save_png(
187 base64_payload: &str,
188 path: &std::path::Path,
189) -> std::io::Result<std::path::PathBuf> {
190 use base64::Engine;
191 if !is_screenshot_response(base64_payload) {
192 return Err(std::io::Error::other(shot_error_message(base64_payload)));
193 }
194 let target = resolve_png_path(path);
195 let b64 = base64_payload
196 .strip_prefix("base64:")
197 .unwrap_or(base64_payload);
198 let bytes = base64::engine::general_purpose::STANDARD
199 .decode(b64)
200 .map_err(|e| std::io::Error::other(format!("base64: {e}")))?;
201 if let Some(parent) = target.parent()
202 && !parent.as_os_str().is_empty()
203 {
204 std::fs::create_dir_all(parent)?;
205 }
206 std::fs::write(&target, &bytes)?;
207 Ok(target)
208}
209
210pub fn parse_attr(s: &str) -> std::result::Result<(String, String), DevtoolsError> {
212 let (k, v) = s
213 .split_once('=')
214 .ok_or_else(|| DevtoolsError::BadArgs(format!("--attr expects k=v, got `{s}`")))?;
215 Ok((k.to_owned(), v.to_owned()))
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 fn req(cmd: &str, body: Option<&str>) -> DevtoolsRequest {
223 DevtoolsRequest {
224 cmd: cmd.to_owned(),
225 body: body.map(|s| s.to_owned()),
226 save_path: None,
227 json: false,
228 }
229 }
230
231 #[test]
233 fn to_wire_simple() {
234 assert_eq!(req("tree", None).to_wire(), "TREE");
235 assert_eq!(req("layout", None).to_wire(), "LAYOUT");
236 assert_eq!(req("shot", None).to_wire(), "SHOT");
237 }
238
239 #[test]
241 fn to_wire_with_body() {
242 assert_eq!(
243 req("find", Some(r#"{"tag":"button","text":"+1"}"#)).to_wire(),
244 "FIND {\"tag\":\"button\",\"text\":\"+1\"}"
245 );
246 assert_eq!(
248 req("type", Some("0.1 hello world")).to_wire(),
249 "TYPE 0.1 hello world"
250 );
251 assert_eq!(req("key", Some("0.1 Enter")).to_wire(), "KEY 0.1 Enter");
252 assert_eq!(req("click", Some("0.0")).to_wire(), "CLICK 0.0");
253 }
254
255 #[test]
257 fn to_wire_state_is_snapshot() {
258 assert_eq!(req("state", None).to_wire(), "SNAPSHOT");
259 }
260
261 #[test]
263 fn parse_attr_roundtrip() {
264 assert_eq!(
265 parse_attr("class=btn").expect("k=v"),
266 ("class".to_owned(), "btn".to_owned())
267 );
268 assert!(parse_attr("no-equals").is_err());
269 }
270
271 #[test]
273 fn shot_error_detection() {
274 assert!(is_screenshot_response("base64:AAAA"));
275 assert!(!is_screenshot_response("unknown command"));
276 assert!(!is_screenshot_response("timeout"));
277 assert!(!is_screenshot_response("error: no frame"));
278 let m = shot_error_message("unknown command");
279 assert!(m.contains("SHOT"), "{m}");
280 assert!(m.contains("устаревша"), "{m}");
281 }
282
283 #[test]
285 fn resolve_png_path_dir_gets_timestamped_name() {
286 let base = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
287 let dir = base.join(format!("_resolve_dir_{}", std::process::id()));
288 let _ = std::fs::remove_dir_all(&dir);
289 std::fs::create_dir_all(&dir).expect("create");
290 let p = resolve_png_path(&dir);
291 assert!(p.starts_with(&dir), "{}", p.display());
292 assert!(
293 p.file_name()
294 .and_then(|n| n.to_str())
295 .unwrap_or("")
296 .ends_with(".png")
297 );
298 let _ = std::fs::remove_dir_all(&dir);
299 }
300
301 #[test]
303 fn resolve_png_path_no_extension() {
304 let p = resolve_png_path(std::path::Path::new("shots/shots"));
305 assert!(
306 p.starts_with(std::path::Path::new("shots/shots")),
307 "{}",
308 p.display()
309 );
310 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
311 assert!(name.ends_with(".png"), "{name}");
312 assert!(name.starts_with("shots_"), "{name}");
313 }
314
315 #[test]
317 fn resolve_png_path_with_png_ext() {
318 let p = resolve_png_path(std::path::Path::new("out/frame.png"));
319 assert_eq!(p, std::path::PathBuf::from("out/frame.png"));
320 }
321
322 #[test]
325 fn save_png_writes_file_and_rejects_unknown_command() {
326 use base64::Engine;
327 let dir = std::env::current_dir()
330 .unwrap_or_else(|_| std::path::PathBuf::from("."))
331 .join(format!("_save_png_test_{}", std::process::id()));
332 let _ = std::fs::remove_dir_all(&dir);
333 std::fs::create_dir_all(&dir).expect("create dir");
334 let png_bytes: Vec<u8> = vec![137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4];
336 let b64 = format!(
337 "base64:{}",
338 base64::engine::general_purpose::STANDARD.encode(&png_bytes)
339 );
340 let p = save_png(&b64, &dir).expect("save");
341 assert!(p.is_file(), "{} должен существовать", p.display());
342 assert_eq!(std::fs::read(&p).unwrap(), png_bytes);
343 let err = save_png("unknown command", &dir).expect_err("должен быть error");
345 assert!(err.to_string().contains("SHOT"), "{}", err);
346 let _ = std::fs::remove_dir_all(&dir);
347 }
348}