Skip to main content

tandem_browser/
lib.rs

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