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