1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use crate::path_utils::{resolve_file_path, validate_path_access};
12
13use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
14
15#[derive(Debug)]
17pub struct WebScreenshotTool;
18
19#[async_trait::async_trait]
20impl BaseTool for WebScreenshotTool {
21 fn name(&self) -> &str {
22 "web_screenshot"
23 }
24
25 fn description(&self) -> &str {
26 "Capture a screenshot of a web page. Saves as PNG using headless Chrome/Chromium, \
27 or falls back to saving page HTML."
28 }
29
30 fn parameter_schema(&self) -> serde_json::Value {
31 serde_json::json!({
32 "type": "object",
33 "properties": {
34 "url": {
35 "type": "string",
36 "description": "URL of the web page to capture"
37 },
38 "output_path": {
39 "type": "string",
40 "description": "Path to save the screenshot (optional, auto-generated if not provided)"
41 },
42 "viewport_width": {
43 "type": "integer",
44 "description": "Browser viewport width in pixels (default: 1920)"
45 },
46 "viewport_height": {
47 "type": "integer",
48 "description": "Browser viewport height in pixels (default: 1080)"
49 },
50 "action": {
51 "type": "string",
52 "description": "Action: 'capture' (default), 'list', or 'clear'",
53 "enum": ["capture", "list", "clear"]
54 }
55 },
56 "required": ["url"]
57 })
58 }
59
60 async fn execute(
61 &self,
62 args: HashMap<String, serde_json::Value>,
63 ctx: &ToolContext,
64 ) -> ToolResult {
65 let action = args
66 .get("action")
67 .and_then(|v| v.as_str())
68 .unwrap_or("capture");
69
70 match action {
71 "list" => list_screenshots(),
72 "clear" => clear_screenshots(5),
73 _ => {
74 let url = match args.get("url").and_then(|v| v.as_str()) {
75 Some(u) if !u.trim().is_empty() => u.trim(),
76 _ => return ToolResult::fail("url is required for screenshot capture"),
77 };
78
79 let output_path = args.get("output_path").and_then(|v| v.as_str());
80 let viewport_width = args
81 .get("viewport_width")
82 .and_then(|v| v.as_u64())
83 .unwrap_or(1920) as u32;
84 let viewport_height = args
85 .get("viewport_height")
86 .and_then(|v| v.as_u64())
87 .unwrap_or(1080) as u32;
88
89 capture_screenshot(url, output_path, viewport_width, viewport_height, ctx).await
90 }
91 }
92 }
93}
94
95fn normalize_url(url: &str) -> String {
97 let url = url.trim();
98 if url.starts_with("https://") || url.starts_with("http://") {
99 return url.to_string();
100 }
101 if url.starts_with("https:/") && !url.starts_with("https://") {
102 return url.replacen("https:/", "https://", 1);
103 }
104 if url.starts_with("http:/") && !url.starts_with("http://") {
105 return url.replacen("http:/", "http://", 1);
106 }
107 format!("https://{url}")
108}
109
110fn screenshot_dir() -> PathBuf {
112 let dir = std::env::temp_dir().join("opendev_web_screenshots");
113 std::fs::create_dir_all(&dir).ok();
114 dir
115}
116
117fn generate_output_path(url: &str) -> PathBuf {
119 let domain = url
121 .trim_start_matches("https://")
122 .trim_start_matches("http://")
123 .split('/')
124 .next()
125 .unwrap_or("page")
126 .replace([':', '/'], "_");
127
128 let timestamp = std::time::SystemTime::now()
129 .duration_since(std::time::UNIX_EPOCH)
130 .unwrap_or_default()
131 .as_millis();
132
133 screenshot_dir().join(format!("{domain}_{timestamp}.png"))
134}
135
136fn find_browser() -> Option<String> {
138 let candidates = [
139 "chromium",
140 "chromium-browser",
141 "google-chrome",
142 "google-chrome-stable",
143 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
144 "/Applications/Chromium.app/Contents/MacOS/Chromium",
145 ];
146
147 for candidate in &candidates {
148 if let Ok(output) = std::process::Command::new("which").arg(candidate).output()
149 && output.status.success()
150 {
151 return Some(candidate.to_string());
152 }
153 if Path::new(candidate).exists() {
155 return Some(candidate.to_string());
156 }
157 }
158 None
159}
160
161async fn capture_screenshot(
163 url: &str,
164 output_path: Option<&str>,
165 viewport_width: u32,
166 viewport_height: u32,
167 ctx: &ToolContext,
168) -> ToolResult {
169 let url = normalize_url(url);
170
171 let dest = match output_path {
173 Some(p) => {
174 let resolved = resolve_file_path(p, &ctx.working_dir);
175 if let Err(msg) = validate_path_access(&resolved, &ctx.working_dir) {
176 return ToolResult::fail(msg);
177 }
178 resolved
179 }
180 None => generate_output_path(&url),
181 };
182
183 if let Some(parent) = dest.parent() {
185 std::fs::create_dir_all(parent).ok();
186 }
187
188 if let Some(browser) = find_browser() {
190 let window_size = format!("--window-size={viewport_width},{viewport_height}");
191 let screenshot_arg = format!("--screenshot={}", dest.display());
192
193 let result = tokio::process::Command::new(&browser)
194 .args([
195 "--headless",
196 "--disable-gpu",
197 "--no-sandbox",
198 "--disable-software-rasterizer",
199 "--disable-dev-shm-usage",
200 &window_size,
201 &screenshot_arg,
202 &url,
203 ])
204 .stdout(std::process::Stdio::piped())
205 .stderr(std::process::Stdio::piped())
206 .output()
207 .await;
208
209 match result {
210 Ok(output) if output.status.success() && dest.exists() => {
211 let size_kb = std::fs::metadata(&dest)
212 .map(|m| m.len() as f64 / 1024.0)
213 .unwrap_or(0.0);
214
215 let mut metadata = HashMap::new();
216 metadata.insert(
217 "screenshot_path".into(),
218 serde_json::json!(dest.to_string_lossy()),
219 );
220 metadata.insert("url".into(), serde_json::json!(url));
221 metadata.insert(
222 "viewport".into(),
223 serde_json::json!(format!("{viewport_width}x{viewport_height}")),
224 );
225 metadata.insert(
226 "screenshot_size_kb".into(),
227 serde_json::json!(format!("{size_kb:.1}")),
228 );
229
230 return ToolResult::ok_with_metadata(
231 format!(
232 "Screenshot saved: {}\nURL: {url}\nViewport: {viewport_width}x{viewport_height}\nSize: {size_kb:.1} KB",
233 dest.display()
234 ),
235 metadata,
236 );
237 }
238 Ok(output) => {
239 let stderr = String::from_utf8_lossy(&output.stderr);
240 tracing::warn!(
241 "Headless Chrome failed (status {}): {stderr}",
242 output.status
243 );
244 }
246 Err(e) => {
247 tracing::warn!("Failed to launch headless Chrome: {e}");
248 }
250 }
251 }
252
253 let client = match reqwest::Client::builder()
255 .timeout(std::time::Duration::from_secs(30))
256 .redirect(reqwest::redirect::Policy::limited(10))
257 .user_agent(
258 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
259 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
260 )
261 .build()
262 {
263 Ok(c) => c,
264 Err(e) => return ToolResult::fail(format!("Failed to create HTTP client: {e}")),
265 };
266
267 let response = match client.get(&url).send().await {
268 Ok(r) => r,
269 Err(e) => return ToolResult::fail(format!("Failed to fetch page: {e}")),
270 };
271
272 let body = match response.text().await {
273 Ok(t) => t,
274 Err(e) => return ToolResult::fail(format!("Failed to read page: {e}")),
275 };
276
277 let html_dest = dest.with_extension("html");
279 match std::fs::write(&html_dest, &body) {
280 Ok(_) => {
281 let mut metadata = HashMap::new();
282 metadata.insert(
283 "screenshot_path".into(),
284 serde_json::json!(html_dest.to_string_lossy()),
285 );
286 metadata.insert("url".into(), serde_json::json!(url));
287 metadata.insert("format".into(), serde_json::json!("html"));
288 metadata.insert(
289 "note".into(),
290 serde_json::json!(
291 "Headless Chrome not available. Saved as HTML. \
292 Install Chrome/Chromium for PNG screenshots."
293 ),
294 );
295
296 ToolResult::ok_with_metadata(
297 format!(
298 "Page saved as HTML: {}\nURL: {url}\n\
299 Note: Install Chrome/Chromium for PNG screenshot support.",
300 html_dest.display()
301 ),
302 metadata,
303 )
304 }
305 Err(e) => ToolResult::fail(format!("Failed to save page: {e}")),
306 }
307}
308
309fn list_screenshots() -> ToolResult {
311 let dir = screenshot_dir();
312 if !dir.exists() {
313 return ToolResult::ok("No screenshots found.");
314 }
315
316 let mut entries: Vec<(PathBuf, std::fs::Metadata)> = Vec::new();
317
318 if let Ok(read_dir) = std::fs::read_dir(&dir) {
319 for entry in read_dir.flatten() {
320 let path = entry.path();
321 if let Some(ext) = path.extension()
322 && (ext == "png" || ext == "html")
323 && let Ok(meta) = entry.metadata()
324 {
325 entries.push((path, meta));
326 }
327 }
328 }
329
330 entries.sort_by(|a, b| {
332 b.1.modified()
333 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
334 .cmp(&a.1.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH))
335 });
336
337 entries.truncate(10);
339
340 if entries.is_empty() {
341 return ToolResult::ok("No screenshots found.");
342 }
343
344 let mut output = format!("Screenshots ({}, showing up to 10):\n\n", entries.len());
345 for (path, meta) in &entries {
346 let size_kb = meta.len() as f64 / 1024.0;
347 output.push_str(&format!(" {} ({:.1} KB)\n", path.display(), size_kb));
348 }
349
350 let mut metadata = HashMap::new();
351 metadata.insert("count".into(), serde_json::json!(entries.len()));
352 metadata.insert("directory".into(), serde_json::json!(dir.to_string_lossy()));
353
354 ToolResult::ok_with_metadata(output, metadata)
355}
356
357fn clear_screenshots(keep_recent: usize) -> ToolResult {
359 let dir = screenshot_dir();
360 if !dir.exists() {
361 return ToolResult::ok("No screenshots directory found.");
362 }
363
364 let mut entries: Vec<PathBuf> = Vec::new();
365
366 if let Ok(read_dir) = std::fs::read_dir(&dir) {
367 for entry in read_dir.flatten() {
368 let path = entry.path();
369 if let Some(ext) = path.extension()
370 && (ext == "png" || ext == "html")
371 {
372 entries.push(path);
373 }
374 }
375 }
376
377 entries.sort_by(|a, b| {
379 let a_time = a
380 .metadata()
381 .and_then(|m| m.modified())
382 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
383 let b_time = b
384 .metadata()
385 .and_then(|m| m.modified())
386 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
387 b_time.cmp(&a_time)
388 });
389
390 let to_delete = if entries.len() > keep_recent {
391 &entries[keep_recent..]
392 } else {
393 &[]
394 };
395
396 let mut deleted = 0;
397 for path in to_delete {
398 if std::fs::remove_file(path).is_ok() {
399 deleted += 1;
400 }
401 }
402
403 let kept = entries.len().saturating_sub(deleted);
404
405 let mut metadata = HashMap::new();
406 metadata.insert("deleted_count".into(), serde_json::json!(deleted));
407 metadata.insert("kept_count".into(), serde_json::json!(kept));
408
409 ToolResult::ok_with_metadata(
410 format!("Cleared {deleted} screenshots, kept {kept}."),
411 metadata,
412 )
413}
414
415#[cfg(test)]
416#[path = "web_screenshot_tests.rs"]
417mod tests;