1#![allow(dead_code, clippy::all)]
2
3use std::collections::HashMap;
4use std::env;
5use std::fs;
6use std::io::{BufRead, BufReader, Write};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::sync::Arc;
10use std::thread;
11use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12
13use anyhow::{anyhow, Context};
14use base64::Engine;
15use headless_chrome::browser::tab::Tab;
16use headless_chrome::{Browser, LaunchOptionsBuilder};
17use html2md::parse_html;
18use regex::Regex;
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Value};
21use tandem_core::resolve_shared_paths;
22use tempfile::TempDir;
23use uuid::Uuid;
24
25pub const BROWSER_PROTOCOL_VERSION: &str = "1";
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct BrowserViewport {
29 pub width: u32,
30 pub height: u32,
31}
32
33impl Default for BrowserViewport {
34 fn default() -> Self {
35 Self {
36 width: 1280,
37 height: 800,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct BrowserBlockingIssue {
44 pub code: String,
45 pub message: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49pub struct BrowserSidecarStatus {
50 pub found: bool,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub path: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub version: Option<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct BrowserExecutableStatus {
59 pub found: bool,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub path: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub version: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub channel: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct BrowserStatus {
70 pub enabled: bool,
71 pub runnable: bool,
72 #[serde(default)]
73 pub headless_default: bool,
74 #[serde(default)]
75 pub sidecar: BrowserSidecarStatus,
76 #[serde(default)]
77 pub browser: BrowserExecutableStatus,
78 #[serde(default)]
79 pub blocking_issues: Vec<BrowserBlockingIssue>,
80 #[serde(default)]
81 pub recommendations: Vec<String>,
82 #[serde(default)]
83 pub install_hints: Vec<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub last_checked_at_ms: Option<u64>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub last_error: Option<String>,
88}
89
90impl Default for BrowserStatus {
91 fn default() -> Self {
92 Self {
93 enabled: false,
94 runnable: false,
95 headless_default: true,
96 sidecar: BrowserSidecarStatus::default(),
97 browser: BrowserExecutableStatus::default(),
98 blocking_issues: Vec::new(),
99 recommendations: Vec::new(),
100 install_hints: Vec::new(),
101 last_checked_at_ms: Some(now_ms()),
102 last_error: None,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct BrowserArtifactRef {
109 pub artifact_id: String,
110 pub uri: String,
111 pub kind: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub label: Option<String>,
114 pub created_at_ms: u64,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub metadata: Option<Value>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120pub struct BrowserElementRef {
121 pub element_id: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub role: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub name: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub text: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub selector_hint: Option<String>,
130 #[serde(default)]
131 pub visible: bool,
132 #[serde(default)]
133 pub enabled: bool,
134 #[serde(default)]
135 pub editable: bool,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub checked: Option<bool>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub bounds: Option<Value>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, Default)]
143pub struct BrowserSnapshotResult {
144 pub session_id: String,
145 pub url: String,
146 pub title: String,
147 pub load_state: String,
148 pub viewport: BrowserViewport,
149 #[serde(default)]
150 pub elements: Vec<BrowserElementRef>,
151 #[serde(default)]
152 pub notices: Vec<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub screenshot_base64: Option<String>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct BrowserWaitCondition {
159 pub kind: String,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub value: Option<String>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, Default)]
165pub struct BrowserWaitParams {
166 pub session_id: String,
167 pub condition: BrowserWaitCondition,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub timeout_ms: Option<u64>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, Default)]
173pub struct BrowserOpenRequest {
174 pub url: String,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub profile_id: Option<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub headless: Option<bool>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub viewport: Option<BrowserViewport>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub wait_until: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub executable_path: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub user_data_root: Option<String>,
187 #[serde(default)]
188 pub allow_no_sandbox: bool,
189 #[serde(default)]
190 pub headless_default: bool,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, Default)]
194pub struct BrowserOpenResult {
195 pub session_id: String,
196 pub final_url: String,
197 pub title: String,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub browser_version: Option<String>,
200 pub headless: bool,
201 pub viewport: BrowserViewport,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, Default)]
205pub struct BrowserNavigateParams {
206 pub session_id: String,
207 pub url: String,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub wait_until: Option<String>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize, Default)]
213pub struct BrowserNavigateResult {
214 pub session_id: String,
215 pub final_url: String,
216 pub title: String,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, Default)]
220pub struct BrowserSnapshotParams {
221 pub session_id: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub max_elements: Option<usize>,
224 #[serde(default)]
225 pub include_screenshot: bool,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize, Default)]
229pub struct BrowserClickParams {
230 pub session_id: String,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub element_id: Option<String>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub selector: Option<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub wait_for: Option<BrowserWaitCondition>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub timeout_ms: Option<u64>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, Default)]
242pub struct BrowserTypeParams {
243 pub session_id: String,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub element_id: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub selector: Option<String>,
248 pub text: String,
249 #[serde(default)]
250 pub replace: bool,
251 #[serde(default)]
252 pub submit: bool,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub timeout_ms: Option<u64>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, Default)]
258pub struct BrowserPressParams {
259 pub session_id: String,
260 pub key: String,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub wait_for: Option<BrowserWaitCondition>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub timeout_ms: Option<u64>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, Default)]
268pub struct BrowserActionResult {
269 pub session_id: String,
270 pub success: bool,
271 pub elapsed_ms: u64,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub final_url: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub title: Option<String>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, Default)]
279pub struct BrowserExtractParams {
280 pub session_id: String,
281 pub format: String,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub max_bytes: Option<usize>,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, Default)]
287pub struct BrowserExtractResult {
288 pub session_id: String,
289 pub format: String,
290 pub content: String,
291 pub truncated: bool,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, Default)]
295pub struct BrowserScreenshotParams {
296 pub session_id: String,
297 #[serde(default)]
298 pub full_page: bool,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub label: Option<String>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize, Default)]
304pub struct BrowserScreenshotResult {
305 pub session_id: String,
306 pub mime_type: String,
307 pub data_base64: String,
308 pub bytes: usize,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub label: Option<String>,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, Default)]
314pub struct BrowserCloseParams {
315 pub session_id: String,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, Default)]
319pub struct BrowserCloseResult {
320 pub session_id: String,
321 pub closed: bool,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, Default)]
325pub struct BrowserDoctorOptions {
326 pub enabled: bool,
327 #[serde(default)]
328 pub headless_default: bool,
329 #[serde(default)]
330 pub allow_no_sandbox: bool,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub executable_path: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub user_data_root: Option<String>,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct BrowserRpcRequest {
339 pub jsonrpc: String,
340 pub id: Value,
341 pub method: String,
342 #[serde(default)]
343 pub params: Value,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct BrowserRpcError {
348 pub code: i64,
349 pub message: String,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub data: Option<Value>,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct BrowserRpcResponse {
356 pub jsonrpc: String,
357 pub id: Value,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub result: Option<Value>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub error: Option<BrowserRpcError>,
362}
363
364impl BrowserRpcResponse {
365 pub fn ok(id: Value, result: Value) -> Self {
366 Self {
367 jsonrpc: "2.0".to_string(),
368 id,
369 result: Some(result),
370 error: None,
371 }
372 }
373
374 pub fn err(id: Value, code: i64, message: impl Into<String>, data: Option<Value>) -> Self {
375 Self {
376 jsonrpc: "2.0".to_string(),
377 id,
378 result: None,
379 error: Some(BrowserRpcError {
380 code,
381 message: message.into(),
382 data,
383 }),
384 }
385 }
386}
387
388#[derive(Debug, Clone)]
389pub struct BrowserServerOptions {
390 pub executable_path: Option<String>,
391 pub user_data_root: Option<String>,
392 pub allow_no_sandbox: bool,
393 pub headless_default: bool,
394}
395
396impl Default for BrowserServerOptions {
397 fn default() -> Self {
398 Self {
399 executable_path: env::var("TANDEM_BROWSER_EXECUTABLE").ok(),
400 user_data_root: env::var("TANDEM_BROWSER_USER_DATA_ROOT").ok(),
401 allow_no_sandbox: env::var("TANDEM_BROWSER_ALLOW_NO_SANDBOX")
402 .ok()
403 .and_then(|raw| parse_bool_like(&raw))
404 .unwrap_or(false),
405 headless_default: env::var("TANDEM_BROWSER_HEADLESS")
406 .ok()
407 .and_then(|raw| parse_bool_like(&raw))
408 .unwrap_or(true),
409 }
410 }
411}
412
413struct BrowserSession {
414 _browser: Browser,
415 tab: Arc<Tab>,
416 viewport: BrowserViewport,
417 _headless: bool,
418 _browser_version: Option<String>,
419 _profile_dir: Option<TempDir>,
420}
421
422pub fn now_ms() -> u64 {
423 SystemTime::now()
424 .duration_since(UNIX_EPOCH)
425 .map(|d| d.as_millis() as u64)
426 .unwrap_or(0)
427}
428
429pub fn current_sidecar_status() -> BrowserSidecarStatus {
430 let path = env::current_exe()
431 .ok()
432 .map(|p| p.to_string_lossy().to_string());
433 BrowserSidecarStatus {
434 found: path.is_some(),
435 path,
436 version: Some(env!("CARGO_PKG_VERSION").to_string()),
437 }
438}
439
440pub fn parse_bool_like(raw: &str) -> Option<bool> {
441 match raw.trim().to_ascii_lowercase().as_str() {
442 "1" | "true" | "yes" | "on" => Some(true),
443 "0" | "false" | "no" | "off" => Some(false),
444 _ => None,
445 }
446}
447
448pub fn detect_browser_executable(explicit: Option<&str>) -> Option<PathBuf> {
449 let mut candidates = Vec::<PathBuf>::new();
450 if let Some(path) = explicit.map(str::trim).filter(|v| !v.is_empty()) {
451 candidates.push(PathBuf::from(path));
452 }
453
454 let names = if cfg!(target_os = "windows") {
455 vec!["chrome.exe", "msedge.exe", "brave.exe", "chromium.exe"]
456 } else if cfg!(target_os = "macos") {
457 vec![
458 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
459 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
460 "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
461 "/Applications/Chromium.app/Contents/MacOS/Chromium",
462 "google-chrome",
463 "microsoft-edge",
464 "brave-browser",
465 "chromium",
466 ]
467 } else {
468 vec![
469 "google-chrome",
470 "google-chrome-stable",
471 "chromium",
472 "chromium-browser",
473 "microsoft-edge",
474 "microsoft-edge-stable",
475 "brave-browser",
476 "brave",
477 ]
478 };
479
480 for name in names {
481 let candidate = PathBuf::from(name);
482 if candidate.is_absolute() {
483 candidates.push(candidate);
484 } else if let Some(found) = find_on_path(name) {
485 candidates.push(found);
486 }
487 }
488
489 if cfg!(target_os = "windows") {
490 for raw in [
491 r"C:\Program Files\Google\Chrome\Application\chrome.exe",
492 r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
493 r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
494 r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
495 r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
496 r"C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe",
497 ] {
498 candidates.push(PathBuf::from(raw));
499 }
500 }
501
502 candidates
503 .into_iter()
504 .find(|path| path.exists() && path.is_file())
505}
506
507pub fn detect_sidecar_binary_path(explicit: Option<&str>) -> Option<PathBuf> {
508 let mut candidates = Vec::<PathBuf>::new();
509 if let Some(raw) = explicit.map(str::trim).filter(|v| !v.is_empty()) {
510 candidates.push(PathBuf::from(raw));
511 }
512 if let Ok(raw) = env::var("TANDEM_BROWSER_SIDECAR") {
513 let trimmed = raw.trim();
514 if !trimmed.is_empty() {
515 candidates.push(PathBuf::from(trimmed));
516 }
517 }
518 if let Ok(paths) = resolve_shared_paths() {
519 candidates.push(
520 paths
521 .canonical_root
522 .join("binaries")
523 .join(sidecar_binary_name()),
524 );
525 }
526 if let Ok(exe) = env::current_exe() {
527 if let Some(parent) = exe.parent() {
528 candidates.push(parent.join(sidecar_binary_name()));
529 candidates.push(parent.join("..").join(sidecar_binary_name()));
530 candidates.push(parent.join("..").join("..").join(sidecar_binary_name()));
531 }
532 }
533 if let Some(path) = find_on_path(sidecar_binary_name()) {
534 candidates.push(path);
535 }
536 candidates
537 .into_iter()
538 .find(|path| path.exists() && path.is_file())
539}
540
541fn sidecar_binary_name() -> &'static str {
542 #[cfg(target_os = "windows")]
543 {
544 "tandem-browser.exe"
545 }
546 #[cfg(not(target_os = "windows"))]
547 {
548 "tandem-browser"
549 }
550}
551
552pub fn run_doctor(options: BrowserDoctorOptions) -> BrowserStatus {
553 let mut status = BrowserStatus {
554 enabled: options.enabled,
555 runnable: false,
556 headless_default: options.headless_default,
557 sidecar: current_sidecar_status(),
558 browser: BrowserExecutableStatus::default(),
559 blocking_issues: Vec::new(),
560 recommendations: Vec::new(),
561 install_hints: Vec::new(),
562 last_checked_at_ms: Some(now_ms()),
563 last_error: None,
564 };
565
566 if !options.enabled {
567 status.blocking_issues.push(BrowserBlockingIssue {
568 code: "disabled_by_config".to_string(),
569 message: "Browser automation is disabled by configuration.".to_string(),
570 });
571 status
572 .recommendations
573 .push("Set `browser.enabled=true` to enable browser automation.".to_string());
574 return status;
575 }
576
577 let browser_path = detect_browser_executable(options.executable_path.as_deref());
578 let Some(browser_path) = browser_path else {
579 status.blocking_issues.push(BrowserBlockingIssue {
580 code: "browser_not_found".to_string(),
581 message: "No compatible Chromium-based browser executable was found.".to_string(),
582 });
583 status.recommendations.push(
584 "Install Chrome, Chromium, Edge, or Brave on the same machine as tandem-engine."
585 .to_string(),
586 );
587 status.install_hints = linux_install_hints();
588 status.recommendations.push(
589 "Set `TANDEM_BROWSER_EXECUTABLE` or `browser.executable_path` if the browser is installed in a non-standard location."
590 .to_string(),
591 );
592 return status;
593 };
594
595 status.browser.found = true;
596 status.browser.path = Some(browser_path.to_string_lossy().to_string());
597 status.browser.channel = Some(detect_browser_channel(&browser_path));
598
599 match browser_version(&browser_path) {
600 Ok(version) => status.browser.version = Some(version),
601 Err(err) => {
602 status.blocking_issues.push(BrowserBlockingIssue {
603 code: "browser_not_executable".to_string(),
604 message: format!(
605 "Found browser executable at `{}`, but failed to query version: {}",
606 browser_path.display(),
607 truncate(&err.to_string(), 200)
608 ),
609 });
610 status.last_error = Some(err.to_string());
611 return status;
612 }
613 }
614
615 match smoke_test_browser(
616 &browser_path,
617 options.allow_no_sandbox,
618 options.user_data_root.as_deref().map(Path::new),
619 options.headless_default,
620 ) {
621 Ok(version) => {
622 if status.browser.version.is_none() {
623 status.browser.version = version;
624 }
625 status.runnable = true;
626 }
627 Err(err) => {
628 let (code, message) = classify_launch_error(&err);
629 status
630 .blocking_issues
631 .push(BrowserBlockingIssue { code, message });
632 status.last_error = Some(err.to_string());
633 status.recommendations.push(
634 "Run `tandem-browser doctor --json` on the host to inspect full browser readiness diagnostics."
635 .to_string(),
636 );
637 if matches!(
638 status.blocking_issues.last().map(|row| row.code.as_str()),
639 Some("missing_shared_libraries")
640 ) {
641 status.install_hints = linux_install_hints();
642 }
643 }
644 }
645
646 status
647}
648
649fn browser_version(path: &Path) -> anyhow::Result<String> {
650 let output = Command::new(path)
651 .arg("--version")
652 .output()
653 .with_context(|| format!("failed to launch `{}` for version probe", path.display()))?;
654 if !output.status.success() {
655 anyhow::bail!(
656 "version probe failed: {}",
657 String::from_utf8_lossy(&output.stderr).trim()
658 );
659 }
660 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
661 if stdout.is_empty() {
662 anyhow::bail!("version probe returned empty stdout");
663 }
664 Ok(stdout)
665}
666
667fn smoke_test_browser(
668 browser_path: &Path,
669 allow_no_sandbox: bool,
670 user_data_root: Option<&Path>,
671 headless_default: bool,
672) -> anyhow::Result<Option<String>> {
673 let mut launch = LaunchOptionsBuilder::default();
674 let profile_dir = if let Some(root) = user_data_root {
675 fs::create_dir_all(root)
676 .with_context(|| format!("failed to create `{}`", root.display()))?;
677 let root = root.join(format!("doctor-{}", Uuid::new_v4()));
678 fs::create_dir_all(&root)
679 .with_context(|| format!("failed to create `{}`", root.display()))?;
680 Some(root)
681 } else {
682 None
683 };
684
685 launch
686 .path(Some(browser_path.to_path_buf()))
687 .headless(headless_default)
688 .sandbox(!allow_no_sandbox)
689 .window_size(Some((1280, 800)));
690 if let Some(path) = profile_dir.as_ref() {
691 launch.user_data_dir(Some(path.to_path_buf()));
692 }
693 let browser = Browser::new(
694 launch
695 .build()
696 .map_err(|err| anyhow!("failed to build launch options: {}", err))?,
697 )?;
698 let tab = browser.new_tab()?;
699 tab.navigate_to("about:blank")?;
700 tab.wait_until_navigated()?;
701 let _ = tab.evaluate("document.readyState", false)?;
702 let version = browser
703 .get_version()
704 .ok()
705 .map(|v| v.product)
706 .filter(|v| !v.trim().is_empty());
707 drop(tab);
708 drop(browser);
709 if let Some(path) = profile_dir {
710 let _ = fs::remove_dir_all(path);
711 }
712 Ok(version)
713}
714
715fn classify_launch_error(err: &anyhow::Error) -> (String, String) {
716 let raw = err.to_string().to_ascii_lowercase();
717 if raw.contains("sandbox") {
718 return (
719 "sandbox_unavailable".to_string(),
720 "Chromium launch failed because sandbox support is unavailable on this host."
721 .to_string(),
722 );
723 }
724 if raw.contains("shared libraries")
725 || raw.contains("libnss3")
726 || raw.contains("libatk")
727 || raw.contains("error while loading shared libraries")
728 {
729 return (
730 "missing_shared_libraries".to_string(),
731 "Chromium is installed, but required shared libraries are missing.".to_string(),
732 );
733 }
734 if raw.contains("permission denied") {
735 return (
736 "browser_not_executable".to_string(),
737 "Configured browser path exists, but is not executable.".to_string(),
738 );
739 }
740 (
741 "browser_launch_failed".to_string(),
742 format!(
743 "Failed to launch Chromium: {}",
744 truncate(&err.to_string(), 220)
745 ),
746 )
747}
748
749fn linux_install_hints() -> Vec<String> {
750 if !cfg!(target_os = "linux") {
751 return Vec::new();
752 }
753 let os_release = fs::read_to_string("/etc/os-release").unwrap_or_default();
754 let distro = Regex::new(r#"(?m)^ID="?([^"\n]+)"?"#)
755 .ok()
756 .and_then(|re| re.captures(&os_release))
757 .and_then(|caps| caps.get(1))
758 .map(|m| m.as_str().to_string())
759 .unwrap_or_default();
760 match distro.as_str() {
761 "ubuntu" | "debian" => vec![
762 "Install Chromium or Chrome with apt, then set TANDEM_BROWSER_EXECUTABLE if needed.".to_string(),
763 "Example: sudo apt update && sudo apt install -y chromium".to_string(),
764 ],
765 "fedora" | "rhel" | "centos" => vec![
766 "Install Chromium with dnf, then set TANDEM_BROWSER_EXECUTABLE if needed.".to_string(),
767 "Example: sudo dnf install -y chromium".to_string(),
768 ],
769 "arch" | "manjaro" => vec![
770 "Install Chromium with pacman, then set TANDEM_BROWSER_EXECUTABLE if needed.".to_string(),
771 "Example: sudo pacman -S chromium".to_string(),
772 ],
773 "alpine" => vec![
774 "Install Chromium and required fonts/libs with apk.".to_string(),
775 "Example: sudo apk add chromium nss freetype harfbuzz ca-certificates ttf-freefont".to_string(),
776 ],
777 _ => vec![
778 "Install a Chromium-based browser on this host and set TANDEM_BROWSER_EXECUTABLE if it is not on PATH.".to_string(),
779 ],
780 }
781}
782
783fn detect_browser_channel(path: &Path) -> String {
784 let name = path
785 .file_name()
786 .and_then(|v| v.to_str())
787 .unwrap_or_default()
788 .to_ascii_lowercase();
789 if name.contains("edge") {
790 "edge".to_string()
791 } else if name.contains("brave") {
792 "brave".to_string()
793 } else if name.contains("chromium") {
794 "chromium".to_string()
795 } else {
796 "chrome".to_string()
797 }
798}
799
800fn find_on_path(name: &str) -> Option<PathBuf> {
801 let path = env::var_os("PATH")?;
802 env::split_paths(&path)
803 .map(|dir| dir.join(name))
804 .find(|candidate| candidate.exists() && candidate.is_file())
805}
806
807fn truncate(text: &str, max_len: usize) -> String {
808 if text.chars().count() <= max_len {
809 return text.to_string();
810 }
811 let mut out = text.chars().take(max_len).collect::<String>();
812 out.push_str("...");
813 out
814}
815
816fn sanitize_profile_id(raw: &str) -> anyhow::Result<String> {
817 let trimmed = raw.trim();
818 if trimmed.is_empty() {
819 anyhow::bail!("profile_id cannot be empty");
820 }
821 let cleaned = trimmed
822 .chars()
823 .map(|ch| {
824 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
825 ch
826 } else {
827 '_'
828 }
829 })
830 .collect::<String>();
831 if cleaned.is_empty() {
832 anyhow::bail!("profile_id is invalid");
833 }
834 Ok(cleaned)
835}
836
837fn ensure_http_url(url: &str) -> anyhow::Result<()> {
838 let trimmed = url.trim();
839 if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
840 return Ok(());
841 }
842 anyhow::bail!("unsupported URL scheme; only http and https are allowed")
843}
844
845fn resolve_user_data_root(explicit: Option<&str>) -> anyhow::Result<PathBuf> {
846 if let Some(raw) = explicit.map(str::trim).filter(|v| !v.is_empty()) {
847 let path = PathBuf::from(raw);
848 fs::create_dir_all(&path)?;
849 return Ok(path);
850 }
851 if let Some(base) = dirs::data_local_dir() {
852 let path = base.join("tandem").join("browser");
853 fs::create_dir_all(&path)?;
854 return Ok(path);
855 }
856 let path = env::current_dir()?.join(".tandem-browser");
857 fs::create_dir_all(&path)?;
858 Ok(path)
859}
860
861fn wait_for_condition(
862 tab: &Arc<Tab>,
863 url_reader: impl Fn() -> anyhow::Result<String>,
864 condition: Option<BrowserWaitCondition>,
865 timeout_ms: Option<u64>,
866) -> anyhow::Result<()> {
867 let Some(condition) = condition else {
868 return Ok(());
869 };
870 let deadline =
871 Instant::now() + Duration::from_millis(timeout_ms.unwrap_or(15_000).clamp(250, 120_000));
872 loop {
873 match condition.kind.as_str() {
874 "selector" => {
875 let selector = condition
876 .value
877 .as_deref()
878 .ok_or_else(|| anyhow!("wait_for.selector requires condition.value"))?;
879 if element_exists(tab, selector)? {
880 return Ok(());
881 }
882 }
883 "text" => {
884 let needle = condition
885 .value
886 .as_deref()
887 .ok_or_else(|| anyhow!("wait_for.text requires condition.value"))?;
888 let body_text =
889 evaluate_string(tab, "document.body ? document.body.innerText || '' : ''")?;
890 if body_text.contains(needle) {
891 return Ok(());
892 }
893 }
894 "url" => {
895 let needle = condition
896 .value
897 .as_deref()
898 .ok_or_else(|| anyhow!("wait_for.url requires condition.value"))?;
899 if url_reader()?.contains(needle) {
900 return Ok(());
901 }
902 }
903 "navigation" => {
904 tab.wait_until_navigated()?;
905 return Ok(());
906 }
907 "network_idle" => {
908 let state = evaluate_string(tab, "document.readyState")?;
909 if state == "complete" {
910 thread::sleep(Duration::from_millis(500));
911 return Ok(());
912 }
913 }
914 other => anyhow::bail!("unsupported wait condition kind `{}`", other),
915 }
916
917 if Instant::now() >= deadline {
918 anyhow::bail!("timed out waiting for `{}` condition", condition.kind);
919 }
920 thread::sleep(Duration::from_millis(100));
921 }
922}
923
924fn element_exists(tab: &Arc<Tab>, selector: &str) -> anyhow::Result<bool> {
925 let script = format!(
926 "Boolean(document.querySelector({}))",
927 serde_json::to_string(selector)?
928 );
929 Ok(evaluate_bool(tab, &script)?)
930}
931
932fn evaluate_bool(tab: &Arc<Tab>, script: &str) -> anyhow::Result<bool> {
933 tab.evaluate(script, false)?
934 .value
935 .and_then(|v| v.as_bool())
936 .ok_or_else(|| anyhow!("script did not return a boolean"))
937}
938
939fn evaluate_string(tab: &Arc<Tab>, script: &str) -> anyhow::Result<String> {
940 tab.evaluate(script, false)?
941 .value
942 .and_then(|v| v.as_str().map(ToString::to_string))
943 .ok_or_else(|| anyhow!("script did not return a string"))
944}
945
946fn evaluate_json(tab: &Arc<Tab>, script: &str) -> anyhow::Result<Value> {
947 tab.evaluate(script, false)?
948 .value
949 .ok_or_else(|| anyhow!("script did not return a JSON value"))
950}
951
952fn tab_url(tab: &Arc<Tab>) -> anyhow::Result<String> {
953 evaluate_string(tab, "window.location.href")
954}
955
956fn tab_title(tab: &Arc<Tab>) -> anyhow::Result<String> {
957 evaluate_string(tab, "document.title")
958}
959
960fn selector_from_ref(element_id: Option<&str>, selector: Option<&str>) -> anyhow::Result<String> {
961 if let Some(raw) = element_id.map(str::trim).filter(|v| !v.is_empty()) {
962 return Ok(format!(r#"[data-tandem-browser-id="{}"]"#, raw));
963 }
964 if let Some(raw) = selector.map(str::trim).filter(|v| !v.is_empty()) {
965 return Ok(raw.to_string());
966 }
967 anyhow::bail!("either element_id or selector is required")
968}
969
970fn clear_element_value(tab: &Arc<Tab>, selector: &str) -> anyhow::Result<()> {
971 let script = format!(
972 r#"(function() {{
973 const el = document.querySelector({selector});
974 if (!el) {{
975 return false;
976 }}
977 if ("value" in el) {{
978 el.value = "";
979 }}
980 el.textContent = "";
981 return true;
982 }})()"#,
983 selector = serde_json::to_string(selector)?,
984 );
985 if !evaluate_bool(tab, &script)? {
986 anyhow::bail!("selector `{}` not found", selector);
987 }
988 Ok(())
989}
990
991fn submit_element(tab: &Arc<Tab>, selector: &str) -> anyhow::Result<()> {
992 let script = format!(
993 r#"(function() {{
994 const el = document.querySelector({selector});
995 if (!el) {{
996 return false;
997 }}
998 if (el.form && typeof el.form.requestSubmit === "function") {{
999 el.form.requestSubmit();
1000 return true;
1001 }}
1002 if (el.form) {{
1003 el.form.submit();
1004 return true;
1005 }}
1006 el.dispatchEvent(new KeyboardEvent("keydown", {{ key: "Enter", bubbles: true }}));
1007 el.dispatchEvent(new KeyboardEvent("keyup", {{ key: "Enter", bubbles: true }}));
1008 return true;
1009 }})()"#,
1010 selector = serde_json::to_string(selector)?,
1011 );
1012 if !evaluate_bool(tab, &script)? {
1013 anyhow::bail!("selector `{}` not found", selector);
1014 }
1015 Ok(())
1016}
1017
1018fn dispatch_key(tab: &Arc<Tab>, key: &str) -> anyhow::Result<()> {
1019 let script = format!(
1020 r#"(function() {{
1021 const key = {key};
1022 const target = document.activeElement || document.body;
1023 if (!target) {{
1024 return false;
1025 }}
1026 target.dispatchEvent(new KeyboardEvent("keydown", {{ key, bubbles: true }}));
1027 target.dispatchEvent(new KeyboardEvent("keyup", {{ key, bubbles: true }}));
1028 return true;
1029 }})()"#,
1030 key = serde_json::to_string(key)?,
1031 );
1032 if !evaluate_bool(tab, &script)? {
1033 anyhow::bail!("failed to dispatch key `{}`", key);
1034 }
1035 Ok(())
1036}
1037
1038fn snapshot_script(max_elements: usize) -> anyhow::Result<String> {
1039 Ok(format!(
1040 r#"(function() {{
1041 const maxElements = {max_elements};
1042 const selectorHint = (el) => {{
1043 if (el.id) return `#${{el.id}}`;
1044 if (el.getAttribute("name")) return `${{el.tagName.toLowerCase()}}[name="${{el.getAttribute("name")}}"]`;
1045 if (el.getAttribute("type")) return `${{el.tagName.toLowerCase()}}[type="${{el.getAttribute("type")}}"]`;
1046 if (el.getAttribute("role")) return `${{el.tagName.toLowerCase()}}[role="${{el.getAttribute("role")}}"]`;
1047 return el.tagName.toLowerCase();
1048 }};
1049 const visible = (el) => {{
1050 const rect = el.getBoundingClientRect();
1051 const style = window.getComputedStyle(el);
1052 return !!style && style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
1053 }};
1054 const role = (el) => el.getAttribute("role") || (["A"].includes(el.tagName) ? "link" : null) || (["BUTTON"].includes(el.tagName) ? "button" : null);
1055 const textOf = (el) => (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 240);
1056 const nameOf = (el) => el.getAttribute("aria-label") || el.getAttribute("name") || el.getAttribute("placeholder") || textOf(el) || null;
1057 const elements = [];
1058 const seen = new Set();
1059 const nodes = Array.from(document.querySelectorAll("a,button,input,textarea,select,[role],[tabindex],[contenteditable='true']"));
1060 for (const el of nodes) {{
1061 if (!visible(el)) continue;
1062 if (seen.has(el)) continue;
1063 seen.add(el);
1064 if (!el.dataset.tandemBrowserId) {{
1065 el.dataset.tandemBrowserId = `tb-${{Math.random().toString(36).slice(2, 10)}}`;
1066 }}
1067 elements.push({{
1068 element_id: el.dataset.tandemBrowserId,
1069 role: role(el),
1070 name: nameOf(el),
1071 text: textOf(el) || null,
1072 selector_hint: selectorHint(el),
1073 visible: true,
1074 enabled: !el.disabled,
1075 editable: !!(el.isContentEditable || ["INPUT", "TEXTAREA"].includes(el.tagName)),
1076 checked: typeof el.checked === "boolean" ? !!el.checked : null,
1077 bounds: {{
1078 x: Math.round(el.getBoundingClientRect().x),
1079 y: Math.round(el.getBoundingClientRect().y),
1080 width: Math.round(el.getBoundingClientRect().width),
1081 height: Math.round(el.getBoundingClientRect().height)
1082 }}
1083 }});
1084 if (elements.length >= maxElements) break;
1085 }}
1086 return {{
1087 url: window.location.href,
1088 title: document.title || "",
1089 load_state: document.readyState || "unknown",
1090 elements,
1091 notices: []
1092 }};
1093 }})()"#,
1094 ))
1095}
1096
1097pub fn run_stdio_server(options: BrowserServerOptions) -> anyhow::Result<()> {
1098 let stdin = std::io::stdin();
1099 let stdout = std::io::stdout();
1100 let mut reader = BufReader::new(stdin.lock());
1101 let mut writer = stdout.lock();
1102 let mut sessions = HashMap::<String, BrowserSession>::new();
1103
1104 loop {
1105 let mut line = String::new();
1106 let bytes = reader.read_line(&mut line)?;
1107 if bytes == 0 {
1108 break;
1109 }
1110 let trimmed = line.trim();
1111 if trimmed.is_empty() {
1112 continue;
1113 }
1114 let request = match serde_json::from_str::<BrowserRpcRequest>(trimmed) {
1115 Ok(request) => request,
1116 Err(err) => {
1117 let response = BrowserRpcResponse::err(
1118 Value::Null,
1119 -32700,
1120 format!("invalid JSON-RPC request: {}", err),
1121 None,
1122 );
1123 writeln!(writer, "{}", serde_json::to_string(&response)?)?;
1124 writer.flush()?;
1125 continue;
1126 }
1127 };
1128 let response = handle_request(&options, &mut sessions, request);
1129 writeln!(writer, "{}", serde_json::to_string(&response)?)?;
1130 writer.flush()?;
1131 }
1132
1133 sessions.clear();
1134 Ok(())
1135}
1136
1137fn handle_request(
1138 options: &BrowserServerOptions,
1139 sessions: &mut HashMap<String, BrowserSession>,
1140 request: BrowserRpcRequest,
1141) -> BrowserRpcResponse {
1142 let id = request.id.clone();
1143 let run = || -> anyhow::Result<Value> {
1144 match request.method.as_str() {
1145 "browser.version" => Ok(json!({
1146 "protocol_version": BROWSER_PROTOCOL_VERSION,
1147 "sidecar_version": env!("CARGO_PKG_VERSION"),
1148 })),
1149 "browser.doctor" => {
1150 let params: BrowserDoctorOptions = serde_json::from_value(request.params)?;
1151 Ok(serde_json::to_value(run_doctor(params))?)
1152 }
1153 "browser.open" => {
1154 let params: BrowserOpenRequest = serde_json::from_value(request.params)?;
1155 let result = open_session(options, sessions, params)?;
1156 Ok(serde_json::to_value(result)?)
1157 }
1158 "browser.navigate" => {
1159 let params: BrowserNavigateParams = serde_json::from_value(request.params)?;
1160 let result = navigate_session(sessions, params)?;
1161 Ok(serde_json::to_value(result)?)
1162 }
1163 "browser.snapshot" => {
1164 let params: BrowserSnapshotParams = serde_json::from_value(request.params)?;
1165 let result = snapshot_session(sessions, params)?;
1166 Ok(serde_json::to_value(result)?)
1167 }
1168 "browser.click" => {
1169 let params: BrowserClickParams = serde_json::from_value(request.params)?;
1170 let result = click_session(sessions, params)?;
1171 Ok(serde_json::to_value(result)?)
1172 }
1173 "browser.type" => {
1174 let params: BrowserTypeParams = serde_json::from_value(request.params)?;
1175 let result = type_session(sessions, params)?;
1176 Ok(serde_json::to_value(result)?)
1177 }
1178 "browser.press" => {
1179 let params: BrowserPressParams = serde_json::from_value(request.params)?;
1180 let result = press_session(sessions, params)?;
1181 Ok(serde_json::to_value(result)?)
1182 }
1183 "browser.wait" => {
1184 let params: BrowserWaitParams = serde_json::from_value(request.params)?;
1185 let result = wait_session(sessions, params)?;
1186 Ok(serde_json::to_value(result)?)
1187 }
1188 "browser.extract" => {
1189 let params: BrowserExtractParams = serde_json::from_value(request.params)?;
1190 let result = extract_session(sessions, params)?;
1191 Ok(serde_json::to_value(result)?)
1192 }
1193 "browser.screenshot" => {
1194 let params: BrowserScreenshotParams = serde_json::from_value(request.params)?;
1195 let result = screenshot_session(sessions, params)?;
1196 Ok(serde_json::to_value(result)?)
1197 }
1198 "browser.close" => {
1199 let params: BrowserCloseParams = serde_json::from_value(request.params)?;
1200 let result = close_session(sessions, params)?;
1201 Ok(serde_json::to_value(result)?)
1202 }
1203 "browser.ping" => Ok(json!({ "ok": true })),
1204 other => anyhow::bail!("unknown method `{}`", other),
1205 }
1206 };
1207
1208 match run() {
1209 Ok(result) => BrowserRpcResponse::ok(id, result),
1210 Err(err) => {
1211 let message = err.to_string();
1212 let code = if message.contains("session") && message.contains("not found") {
1213 404
1214 } else if message.contains("selector") && message.contains("not found") {
1215 422
1216 } else {
1217 500
1218 };
1219 BrowserRpcResponse::err(
1220 id,
1221 code,
1222 message,
1223 Some(json!({ "protocol_version": BROWSER_PROTOCOL_VERSION })),
1224 )
1225 }
1226 }
1227}
1228
1229fn open_session(
1230 options: &BrowserServerOptions,
1231 sessions: &mut HashMap<String, BrowserSession>,
1232 params: BrowserOpenRequest,
1233) -> anyhow::Result<BrowserOpenResult> {
1234 ensure_http_url(¶ms.url)?;
1235 let viewport = params.viewport.unwrap_or_default();
1236 let headless = params.headless.unwrap_or(options.headless_default);
1237 if cfg!(target_os = "linux")
1238 && !headless
1239 && env::var("DISPLAY").is_err()
1240 && env::var("WAYLAND_DISPLAY").is_err()
1241 {
1242 anyhow::bail!("headed_mode_unavailable: no DISPLAY or WAYLAND_DISPLAY is available");
1243 }
1244
1245 let executable = detect_browser_executable(
1246 params
1247 .executable_path
1248 .as_deref()
1249 .or(options.executable_path.as_deref()),
1250 )
1251 .ok_or_else(|| anyhow!("browser_not_found: no Chromium executable found"))?;
1252 let user_data_root = resolve_user_data_root(
1253 params
1254 .user_data_root
1255 .as_deref()
1256 .or(options.user_data_root.as_deref()),
1257 )?;
1258 let (profile_dir, temp_dir) = if let Some(profile_id) = params.profile_id.as_deref() {
1259 let profile_id = sanitize_profile_id(profile_id)?;
1260 let path = user_data_root.join(profile_id);
1261 fs::create_dir_all(&path)?;
1262 (path, None)
1263 } else {
1264 let dir = tempfile::Builder::new()
1265 .prefix("tandem-browser-")
1266 .tempdir_in(user_data_root)?;
1267 (dir.path().to_path_buf(), Some(dir))
1268 };
1269
1270 let mut launch = LaunchOptionsBuilder::default();
1271 launch
1272 .path(Some(executable))
1273 .headless(headless)
1274 .sandbox(!(params.allow_no_sandbox || options.allow_no_sandbox))
1275 .window_size(Some((viewport.width, viewport.height)))
1276 .user_data_dir(Some(profile_dir));
1277 let browser = Browser::new(
1278 launch
1279 .build()
1280 .map_err(|err| anyhow!("failed to build launch options: {}", err))?,
1281 )?;
1282 let browser_version = browser
1283 .get_version()
1284 .ok()
1285 .map(|v| v.product)
1286 .filter(|v| !v.trim().is_empty());
1287 let tab = browser.new_tab()?;
1288 tab.navigate_to(¶ms.url)?;
1289 wait_for_condition(
1290 &tab,
1291 || tab_url(&tab),
1292 params
1293 .wait_until
1294 .map(|kind| BrowserWaitCondition { kind, value: None }),
1295 Some(20_000),
1296 )?;
1297
1298 let final_url = tab_url(&tab)?;
1299 let title = tab_title(&tab)?;
1300 let session_id = format!("browser-{}", Uuid::new_v4());
1301 sessions.insert(
1302 session_id.clone(),
1303 BrowserSession {
1304 _browser: browser,
1305 tab,
1306 viewport: viewport.clone(),
1307 _headless: headless,
1308 _browser_version: browser_version.clone(),
1309 _profile_dir: temp_dir,
1310 },
1311 );
1312
1313 Ok(BrowserOpenResult {
1314 session_id,
1315 final_url,
1316 title,
1317 browser_version,
1318 headless,
1319 viewport,
1320 })
1321}
1322
1323fn with_session<T>(
1324 sessions: &mut HashMap<String, BrowserSession>,
1325 session_id: &str,
1326 f: impl FnOnce(&mut BrowserSession) -> anyhow::Result<T>,
1327) -> anyhow::Result<T> {
1328 let session = sessions
1329 .get_mut(session_id)
1330 .ok_or_else(|| anyhow!("session `{}` not found", session_id))?;
1331 f(session)
1332}
1333
1334fn navigate_session(
1335 sessions: &mut HashMap<String, BrowserSession>,
1336 params: BrowserNavigateParams,
1337) -> anyhow::Result<BrowserNavigateResult> {
1338 ensure_http_url(¶ms.url)?;
1339 with_session(sessions, ¶ms.session_id, |session| {
1340 session.tab.navigate_to(¶ms.url)?;
1341 wait_for_condition(
1342 &session.tab,
1343 || tab_url(&session.tab),
1344 params.wait_until.as_ref().map(|kind| BrowserWaitCondition {
1345 kind: kind.clone(),
1346 value: None,
1347 }),
1348 Some(20_000),
1349 )?;
1350 Ok(BrowserNavigateResult {
1351 session_id: params.session_id.clone(),
1352 final_url: tab_url(&session.tab)?,
1353 title: tab_title(&session.tab)?,
1354 })
1355 })
1356}
1357
1358fn snapshot_session(
1359 sessions: &mut HashMap<String, BrowserSession>,
1360 params: BrowserSnapshotParams,
1361) -> anyhow::Result<BrowserSnapshotResult> {
1362 with_session(sessions, ¶ms.session_id, |session| {
1363 let started = Instant::now();
1364 let raw = evaluate_json(
1365 &session.tab,
1366 &snapshot_script(params.max_elements.unwrap_or(50).clamp(1, 200))?,
1367 )?;
1368 let mut snapshot: BrowserSnapshotResult = serde_json::from_value(json!({
1369 "session_id": params.session_id,
1370 "url": raw.get("url").cloned().unwrap_or_else(|| Value::String(String::new())),
1371 "title": raw.get("title").cloned().unwrap_or_else(|| Value::String(String::new())),
1372 "load_state": raw.get("load_state").cloned().unwrap_or_else(|| Value::String("unknown".to_string())),
1373 "viewport": session.viewport,
1374 "elements": raw.get("elements").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
1375 "notices": raw.get("notices").cloned().unwrap_or_else(|| Value::Array(Vec::new()))
1376 }))?;
1377 if params.include_screenshot {
1378 let bytes = session.tab.capture_screenshot(
1379 headless_chrome::protocol::cdp::Page::CaptureScreenshotFormatOption::Png,
1380 None,
1381 None,
1382 true,
1383 )?;
1384 snapshot.screenshot_base64 =
1385 Some(base64::engine::general_purpose::STANDARD.encode(bytes));
1386 }
1387 snapshot.notices.push(format!(
1388 "snapshot_completed_in_ms={}",
1389 started.elapsed().as_millis()
1390 ));
1391 Ok(snapshot)
1392 })
1393}
1394
1395fn click_session(
1396 sessions: &mut HashMap<String, BrowserSession>,
1397 params: BrowserClickParams,
1398) -> anyhow::Result<BrowserActionResult> {
1399 with_session(sessions, ¶ms.session_id, |session| {
1400 let started = Instant::now();
1401 let selector = selector_from_ref(params.element_id.as_deref(), params.selector.as_deref())?;
1402 let element = session.tab.wait_for_element(&selector)?;
1403 element.click()?;
1404 wait_for_condition(
1405 &session.tab,
1406 || tab_url(&session.tab),
1407 params.wait_for.clone(),
1408 params.timeout_ms,
1409 )?;
1410 Ok(BrowserActionResult {
1411 session_id: params.session_id.clone(),
1412 success: true,
1413 elapsed_ms: started.elapsed().as_millis() as u64,
1414 final_url: Some(tab_url(&session.tab)?),
1415 title: Some(tab_title(&session.tab)?),
1416 })
1417 })
1418}
1419
1420fn type_session(
1421 sessions: &mut HashMap<String, BrowserSession>,
1422 params: BrowserTypeParams,
1423) -> anyhow::Result<BrowserActionResult> {
1424 with_session(sessions, ¶ms.session_id, |session| {
1425 let started = Instant::now();
1426 let selector = selector_from_ref(params.element_id.as_deref(), params.selector.as_deref())?;
1427 if params.replace {
1428 clear_element_value(&session.tab, &selector)?;
1429 }
1430 let element = session.tab.wait_for_element(&selector)?;
1431 element.click()?;
1432 element.type_into(¶ms.text)?;
1433 if params.submit {
1434 submit_element(&session.tab, &selector)?;
1435 }
1436 Ok(BrowserActionResult {
1437 session_id: params.session_id.clone(),
1438 success: true,
1439 elapsed_ms: started.elapsed().as_millis() as u64,
1440 final_url: Some(tab_url(&session.tab)?),
1441 title: Some(tab_title(&session.tab)?),
1442 })
1443 })
1444}
1445
1446fn press_session(
1447 sessions: &mut HashMap<String, BrowserSession>,
1448 params: BrowserPressParams,
1449) -> anyhow::Result<BrowserActionResult> {
1450 with_session(sessions, ¶ms.session_id, |session| {
1451 let started = Instant::now();
1452 dispatch_key(&session.tab, ¶ms.key)?;
1453 wait_for_condition(
1454 &session.tab,
1455 || tab_url(&session.tab),
1456 params.wait_for.clone(),
1457 params.timeout_ms,
1458 )?;
1459 Ok(BrowserActionResult {
1460 session_id: params.session_id.clone(),
1461 success: true,
1462 elapsed_ms: started.elapsed().as_millis() as u64,
1463 final_url: Some(tab_url(&session.tab)?),
1464 title: Some(tab_title(&session.tab)?),
1465 })
1466 })
1467}
1468
1469fn wait_session(
1470 sessions: &mut HashMap<String, BrowserSession>,
1471 params: BrowserWaitParams,
1472) -> anyhow::Result<BrowserActionResult> {
1473 with_session(sessions, ¶ms.session_id, |session| {
1474 let started = Instant::now();
1475 wait_for_condition(
1476 &session.tab,
1477 || tab_url(&session.tab),
1478 Some(params.condition.clone()),
1479 params.timeout_ms,
1480 )?;
1481 Ok(BrowserActionResult {
1482 session_id: params.session_id.clone(),
1483 success: true,
1484 elapsed_ms: started.elapsed().as_millis() as u64,
1485 final_url: Some(tab_url(&session.tab)?),
1486 title: Some(tab_title(&session.tab)?),
1487 })
1488 })
1489}
1490
1491fn extract_session(
1492 sessions: &mut HashMap<String, BrowserSession>,
1493 params: BrowserExtractParams,
1494) -> anyhow::Result<BrowserExtractResult> {
1495 with_session(sessions, ¶ms.session_id, |session| {
1496 let max_bytes = params.max_bytes.unwrap_or(256_000).clamp(1_024, 2_000_000);
1497 let (format, mut content) = match params.format.as_str() {
1498 "html" => (
1499 "html".to_string(),
1500 evaluate_string(
1501 &session.tab,
1502 "document.documentElement ? document.documentElement.outerHTML || '' : ''",
1503 )?,
1504 ),
1505 "markdown" => {
1506 let html = evaluate_string(
1507 &session.tab,
1508 "document.documentElement ? document.documentElement.outerHTML || '' : ''",
1509 )?;
1510 ("markdown".to_string(), parse_html(&html))
1511 }
1512 "visible_text" | "text" => (
1513 "visible_text".to_string(),
1514 evaluate_string(
1515 &session.tab,
1516 "document.body ? document.body.innerText || '' : ''",
1517 )?,
1518 ),
1519 other => anyhow::bail!("unsupported extract format `{}`", other),
1520 };
1521 let mut truncated = false;
1522 if content.len() > max_bytes {
1523 content.truncate(max_bytes);
1524 truncated = true;
1525 }
1526 Ok(BrowserExtractResult {
1527 session_id: params.session_id.clone(),
1528 format,
1529 content,
1530 truncated,
1531 })
1532 })
1533}
1534
1535fn screenshot_session(
1536 sessions: &mut HashMap<String, BrowserSession>,
1537 params: BrowserScreenshotParams,
1538) -> anyhow::Result<BrowserScreenshotResult> {
1539 with_session(sessions, ¶ms.session_id, |session| {
1540 let bytes = session.tab.capture_screenshot(
1541 headless_chrome::protocol::cdp::Page::CaptureScreenshotFormatOption::Png,
1542 None,
1543 None,
1544 params.full_page,
1545 )?;
1546 Ok(BrowserScreenshotResult {
1547 session_id: params.session_id.clone(),
1548 mime_type: "image/png".to_string(),
1549 bytes: bytes.len(),
1550 data_base64: base64::engine::general_purpose::STANDARD.encode(bytes),
1551 label: params.label.clone(),
1552 })
1553 })
1554}
1555
1556fn close_session(
1557 sessions: &mut HashMap<String, BrowserSession>,
1558 params: BrowserCloseParams,
1559) -> anyhow::Result<BrowserCloseResult> {
1560 let removed = sessions.remove(¶ms.session_id);
1561 Ok(BrowserCloseResult {
1562 session_id: params.session_id,
1563 closed: removed.is_some(),
1564 })
1565}