Skip to main content

tandem_browser/
lib.rs

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(&params.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(&params.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(&params.url)?;
1337    with_session(sessions, &params.session_id, |session| {
1338        session.tab.navigate_to(&params.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, &params.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, &params.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, &params.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(&params.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, &params.session_id, |session| {
1449        let started = Instant::now();
1450        dispatch_key(&session.tab, &params.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, &params.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, &params.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, &params.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(&params.session_id);
1559    Ok(BrowserCloseResult {
1560        session_id: params.session_id,
1561        closed: removed.is_some(),
1562    })
1563}