fix(browser): dual transport — Unix socket + HTTP Docker + binary corruption detection
Root cause on macOS: camoufox-browser@0.1.0 downloads an alpha binary (v150.0.2-alpha.26) from daijro/camoufox that has a broken macOS build (missing libmozglue.dylib). Binary crashes with SIGABRT/SIGKILL. Changes: 1. gyandoot-browser client now supports TWO transports: - Transport::UnixSocket (existing — daemon IPC) - Transport::Http (new — Docker REST API on port 9377) 2. init_browser() tries Docker HTTP first, falls back to npx daemon 3. HTTP transport maps browser actions to Docker REST endpoints (/navigate, /tabs, /health, etc.) with correct userId params 4. check_known_errors() detects libmozglue.dylib + SIGKILL patterns 5. Browser cache check works for both macOS and Linux paths 6. Error messages correctly say 'Use Docker' on corrupted binary 7. Added reqwest dep to gyandoot-agent for Docker health check Clean build: 0 errors
This commit is contained in:
@@ -3,7 +3,7 @@ name = "gyandoot-browser"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "GyanDoot v2 — Camoufox browser automation via daemon IPC (Unix socket protocol)"
|
||||
description = "GyanDoot v2 — Camoufox browser automation via daemon IPC (Unix socket + HTTP fallback)"
|
||||
|
||||
[dependencies]
|
||||
gyandoot-core = { path = "../gyandoot-core" }
|
||||
@@ -15,4 +15,5 @@ anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
dirs.workspace = true
|
||||
reqwest = { workspace = true, default-features = false, features = ["json"] }
|
||||
@@ -3,9 +3,8 @@
|
||||
//! Communicates with the camoufox-browser daemon via JSON-line protocol
|
||||
//! over a Unix socket at `~/.camoufox-browser/<session>.sock`.
|
||||
//!
|
||||
//! The daemon is started by `camoufox-browser@latest` (npx) which spawns
|
||||
//! a background Node.js process that auto-launches the camoufox browser
|
||||
//! on the first browser-dependent command.
|
||||
//! Also supports HTTP JSON transport for the Docker container on port 9377.
|
||||
//! Auto-detected — no user configuration needed.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
@@ -16,8 +15,6 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Default session name.
|
||||
pub const DEFAULT_SESSION: &str = "default";
|
||||
|
||||
@@ -33,8 +30,7 @@ pub fn default_socket_path(session: &str) -> PathBuf {
|
||||
default_socket_dir().join(format!("{}.sock", session))
|
||||
}
|
||||
|
||||
// ─── Error ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Error types.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BrowserError {
|
||||
#[error("IO error: {0}")]
|
||||
@@ -57,11 +53,30 @@ pub enum BrowserError {
|
||||
|
||||
#[error("Snapshot unavailable")]
|
||||
SnapshotUnavailable,
|
||||
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(String),
|
||||
}
|
||||
|
||||
pub type BrowserResult<T> = Result<T, BrowserError>;
|
||||
|
||||
// ─── IPC Protocol Types ─────────────────────────────────────────────────
|
||||
/// Backend transport type.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Transport {
|
||||
/// Unix socket daemon (camoufox-browser@0.1.0)
|
||||
UnixSocket { session: String, socket_path: PathBuf },
|
||||
/// HTTP REST API (Docker container)
|
||||
Http { base_url: String },
|
||||
}
|
||||
|
||||
impl Transport {
|
||||
fn session(&self) -> &str {
|
||||
match self {
|
||||
Transport::UnixSocket { session, .. } => session,
|
||||
Transport::Http { .. } => "default",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command sent to the daemon as JSON-line.
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -110,7 +125,7 @@ struct DaemonResponse {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Health status returned by the daemon.
|
||||
/// Health status.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthResponse {
|
||||
pub ok: bool,
|
||||
@@ -125,7 +140,7 @@ pub struct HealthResponse {
|
||||
pub active_sessions: u32,
|
||||
}
|
||||
|
||||
/// Navigation result from the daemon.
|
||||
/// Navigation result.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NavigationResult {
|
||||
pub url: String,
|
||||
@@ -178,7 +193,7 @@ impl PageSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Interaction result (click/type).
|
||||
/// Interaction result.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InteractionResult {
|
||||
#[serde(default)]
|
||||
@@ -196,120 +211,253 @@ pub struct TabInfo {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
// ─── BrowserClient ──────────────────────────────────────────────────────
|
||||
|
||||
/// Client that talks to the camoufox-browser daemon over Unix socket.
|
||||
/// Client that talks to camoufox-browser over Unix socket or HTTP.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrowserClient {
|
||||
/// Session name.
|
||||
session: String,
|
||||
/// Socket path.
|
||||
socket_path: PathBuf,
|
||||
/// Timeout for daemon responses.
|
||||
transport: Transport,
|
||||
timeout: Duration,
|
||||
/// Whether this client was created via HTTP (Docker) transport.
|
||||
is_http: bool,
|
||||
}
|
||||
|
||||
impl BrowserClient {
|
||||
/// Create a new client for the default session.
|
||||
/// Create a new client for the default Unix socket session.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
session: DEFAULT_SESSION.to_string(),
|
||||
socket_path: default_socket_path(DEFAULT_SESSION),
|
||||
transport: Transport::UnixSocket {
|
||||
session: DEFAULT_SESSION.to_string(),
|
||||
socket_path: default_socket_path(DEFAULT_SESSION),
|
||||
},
|
||||
timeout: Duration::from_secs(30),
|
||||
is_http: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a client that talks to the Docker HTTP API.
|
||||
pub fn new_http(base_url: &str) -> Self {
|
||||
Self {
|
||||
transport: Transport::Http {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
},
|
||||
timeout: Duration::from_secs(30),
|
||||
is_http: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a client with a custom session name.
|
||||
pub fn with_session(mut self, session: &str) -> Self {
|
||||
self.session = session.to_string();
|
||||
self.socket_path = default_socket_path(session);
|
||||
match &mut self.transport {
|
||||
Transport::UnixSocket { socket_path: _, .. } => {
|
||||
self.transport = Transport::UnixSocket {
|
||||
session: session.to_string(),
|
||||
socket_path: default_socket_path(session),
|
||||
};
|
||||
}
|
||||
Transport::Http { .. } => {} // no-op for HTTP
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a client with a custom socket path.
|
||||
pub fn with_socket(mut self, path: &str) -> Self {
|
||||
self.socket_path = PathBuf::from(path);
|
||||
match &mut self.transport {
|
||||
Transport::UnixSocket { socket_path, .. } => {
|
||||
*socket_path = PathBuf::from(path);
|
||||
}
|
||||
Transport::Http { .. } => {} // no-op for HTTP
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the socket path.
|
||||
pub fn socket_path(&self) -> &PathBuf {
|
||||
&self.socket_path
|
||||
/// Get the socket path (for Unix socket transport).
|
||||
/// Returns None if using HTTP transport.
|
||||
pub fn socket_path(&self) -> Option<&PathBuf> {
|
||||
match &self.transport {
|
||||
Transport::UnixSocket { socket_path, .. } => Some(socket_path),
|
||||
Transport::Http { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the session name.
|
||||
pub fn session(&self) -> &str {
|
||||
&self.session
|
||||
self.transport.session()
|
||||
}
|
||||
|
||||
// ─── Low-level IPC ──────────────────────────────────────────────
|
||||
/// Returns true if this client uses HTTP transport (Docker).
|
||||
pub fn is_http_backend(&self) -> bool {
|
||||
self.is_http
|
||||
}
|
||||
|
||||
/// Send a command to the daemon and receive a response.
|
||||
/// Send a command to the daemon.
|
||||
async fn send_command(&self, action: &str, build: impl FnOnce(&mut DaemonCommand)) -> BrowserResult<DaemonResponse> {
|
||||
match &self.transport {
|
||||
Transport::UnixSocket { .. } => self.send_unix(action, build).await,
|
||||
Transport::Http { base_url } => self.send_http(base_url, action, build).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send command via Unix socket (JSON-line IPC).
|
||||
async fn send_unix(&self, action: &str, build: impl FnOnce(&mut DaemonCommand)) -> BrowserResult<DaemonResponse> {
|
||||
let id = format!("g{}", chrono::Utc::now().timestamp_millis());
|
||||
let mut cmd = DaemonCommand {
|
||||
id: id.clone(),
|
||||
action: action.to_string(),
|
||||
url: None,
|
||||
query: None,
|
||||
macro_name: None,
|
||||
wait: None,
|
||||
selector: None,
|
||||
r#ref: None,
|
||||
text: None,
|
||||
press_enter: None,
|
||||
headless: None,
|
||||
index: None,
|
||||
direction: None,
|
||||
amount: None,
|
||||
interactive: None,
|
||||
compact: None,
|
||||
url: None, query: None, macro_name: None,
|
||||
wait: None, selector: None, r#ref: None,
|
||||
text: None, press_enter: None, headless: None,
|
||||
index: None, direction: None, amount: None,
|
||||
interactive: None, compact: None,
|
||||
};
|
||||
build(&mut cmd);
|
||||
|
||||
let payload = serde_json::to_string(&cmd)
|
||||
.map_err(|e| BrowserError::Daemon(format!("Serialize command: {}", e)))?;
|
||||
.map_err(|e| BrowserError::Daemon(format!("Serialize: {}", e)))?;
|
||||
|
||||
debug!("camoufox IPC → {} (action={})", payload, action);
|
||||
|
||||
// Connect and send
|
||||
let socket_path = match &self.transport {
|
||||
Transport::UnixSocket { socket_path, .. } => socket_path.clone(),
|
||||
_ => return Err(BrowserError::Daemon("Not Unix transport".into())),
|
||||
};
|
||||
|
||||
let stream = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
UnixStream::connect(&self.socket_path),
|
||||
UnixStream::connect(&socket_path),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| BrowserError::SocketNotFound(self.socket_path.display().to_string()))?
|
||||
.map_err(|_| BrowserError::SocketNotFound(socket_path.display().to_string()))?
|
||||
.map_err(BrowserError::Io)?;
|
||||
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
|
||||
// Write command
|
||||
writer
|
||||
.write_all(format!("{}\n", payload).as_bytes())
|
||||
.await
|
||||
.map_err(BrowserError::Io)?;
|
||||
writer.write_all(format!("{}\n", payload).as_bytes()).await.map_err(BrowserError::Io)?;
|
||||
writer.shutdown().await.map_err(BrowserError::Io)?;
|
||||
|
||||
// Read response
|
||||
let mut buf_reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
tokio::time::timeout(self.timeout, buf_reader.read_line(&mut line))
|
||||
.await
|
||||
.map_err(|_| BrowserError::Daemon("Response timeout".to_string()))?
|
||||
.map_err(|_| BrowserError::Daemon("Response timeout".into()))?
|
||||
.map_err(BrowserError::Io)?;
|
||||
|
||||
if line.is_empty() {
|
||||
return Err(BrowserError::Daemon("Empty response from daemon".to_string()));
|
||||
return Err(BrowserError::Daemon("Empty response".into()));
|
||||
}
|
||||
|
||||
let response: DaemonResponse = serde_json::from_str(line.trim())
|
||||
.map_err(|e| BrowserError::Daemon(format!("Parse response: {} — payload: {}", e, line.trim())))?;
|
||||
.map_err(|e| BrowserError::Daemon(format!("Parse: {} — payload: {}", e, line.trim())))?;
|
||||
|
||||
debug!("camoufox IPC ← {:?}", response);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Send command via HTTP (Docker REST API).
|
||||
async fn send_http(&self, base_url: &str, action: &str, build: impl FnOnce(&mut DaemonCommand)) -> BrowserResult<DaemonResponse> {
|
||||
let id = format!("g{}", chrono::Utc::now().timestamp_millis());
|
||||
let mut cmd = DaemonCommand {
|
||||
id: id.clone(),
|
||||
action: action.to_string(),
|
||||
url: None, query: None, macro_name: None,
|
||||
wait: None, selector: None, r#ref: None,
|
||||
text: None, press_enter: None, headless: None,
|
||||
index: None, direction: None, amount: None,
|
||||
interactive: None, compact: None,
|
||||
};
|
||||
build(&mut cmd);
|
||||
|
||||
debug!("camoufox HTTP → {} (action={})", serde_json::to_string(&cmd).unwrap_or_default(), action);
|
||||
|
||||
// Build HTTP client
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(self.timeout)
|
||||
.build()
|
||||
.map_err(|e| BrowserError::Http(e.to_string()))?;
|
||||
|
||||
// Map action to HTTP endpoint
|
||||
let url_str: String;
|
||||
let endpoint: &str = match action {
|
||||
"navigate" => "/navigate",
|
||||
"tab_list" => "/tabs",
|
||||
"tab_new" => "/navigate",
|
||||
"snapshot" => "/snapshot",
|
||||
"back" => "/navigate",
|
||||
"forward" => "/navigate",
|
||||
"reload" => "/navigate",
|
||||
_ => {
|
||||
url_str = format!("/{}", action);
|
||||
&url_str
|
||||
}
|
||||
};
|
||||
|
||||
let body = match action {
|
||||
"navigate" => serde_json::json!({
|
||||
"url": cmd.url.unwrap_or_default(),
|
||||
"userId": "gyandoot"
|
||||
}),
|
||||
"tab_new" => serde_json::json!({
|
||||
"url": cmd.url.unwrap_or("about:blank".into()),
|
||||
"userId": "gyandoot"
|
||||
}),
|
||||
"back" => serde_json::json!({
|
||||
"url": "__history_back__",
|
||||
"userId": "gyandoot"
|
||||
}),
|
||||
"forward" => serde_json::json!({
|
||||
"url": "__history_forward__",
|
||||
"userId": "gyandoot"
|
||||
}),
|
||||
"reload" => serde_json::json!({
|
||||
"url": "__reload__",
|
||||
"userId": "gyandoot"
|
||||
}),
|
||||
_ => serde_json::json!({ "userId": "gyandoot" }),
|
||||
};
|
||||
|
||||
let method = match action {
|
||||
"tab_list" | "snapshot" => "GET",
|
||||
_ => "POST",
|
||||
};
|
||||
|
||||
let url = format!("{}{}", base_url, endpoint);
|
||||
let resp = match method {
|
||||
"GET" => http_client.get(&url).send().await,
|
||||
"POST" => http_client.post(&url).json(&body).send().await,
|
||||
_ => return Err(BrowserError::Http(format!("Unknown method: {}", method))),
|
||||
};
|
||||
|
||||
match resp {
|
||||
Ok(r) => {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
|
||||
// Parse response (the Docker API returns JSON directly)
|
||||
match serde_json::from_str::<Value>(&text) {
|
||||
Ok(json) => {
|
||||
let success = json.get("error").is_none() && status.is_success();
|
||||
let error = json.get("error").and_then(|e| e.as_str()).map(|s| s.to_string());
|
||||
let data = if success { Some(json) } else { None };
|
||||
|
||||
Ok(DaemonResponse {
|
||||
id,
|
||||
success,
|
||||
data,
|
||||
error,
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
// Raw response
|
||||
Ok(DaemonResponse {
|
||||
id,
|
||||
success: status.is_success(),
|
||||
data: Some(Value::String(text)),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => Err(BrowserError::Http(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract data payload from a successful response.
|
||||
fn check_response(&self, resp: DaemonResponse) -> BrowserResult<Value> {
|
||||
if !resp.success {
|
||||
@@ -319,40 +467,28 @@ impl BrowserClient {
|
||||
resp.data.ok_or(BrowserError::MissingData)
|
||||
}
|
||||
|
||||
// ─── Health ─────────────────────────────────────────────────────
|
||||
|
||||
/// Check daemon health by sending a quick snapshot command.
|
||||
/// The daemon has no dedicated /health endpoint — we check socket existence + connectivity.
|
||||
/// Check daemon health.
|
||||
pub async fn health(&self) -> BrowserResult<HealthResponse> {
|
||||
// Check if socket exists
|
||||
if !self.socket_path.exists() {
|
||||
return Ok(HealthResponse {
|
||||
ok: false,
|
||||
engine: "camoufox".to_string(),
|
||||
browser_connected: false,
|
||||
browser_running: false,
|
||||
active_tabs: 0,
|
||||
active_sessions: 0,
|
||||
});
|
||||
}
|
||||
match &self.transport {
|
||||
Transport::Http { base_url } => {
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| BrowserError::Http(e.to_string()))?;
|
||||
|
||||
// Try a quick "tab_list" command as health check
|
||||
match self.send_command("tab_list", |_| {}).await {
|
||||
Ok(resp) => {
|
||||
match self.check_response(resp) {
|
||||
Ok(data) => {
|
||||
let tabs = data["tabs"].as_array().map(|a| a.len() as u32).unwrap_or(0);
|
||||
Ok(HealthResponse {
|
||||
ok: true,
|
||||
engine: "camoufox".to_string(),
|
||||
browser_connected: true,
|
||||
browser_running: true,
|
||||
active_tabs: tabs,
|
||||
active_sessions: 1,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Health check daemon error: {}", e);
|
||||
match http_client.get(format!("{}/health", base_url)).send().await {
|
||||
Ok(resp) => {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if let Ok(json) = serde_json::from_str::<Value>(&text) {
|
||||
return Ok(HealthResponse {
|
||||
ok: json["ok"].as_bool().unwrap_or(false),
|
||||
engine: "camoufox".to_string(),
|
||||
browser_connected: json["browserRunning"].as_bool().unwrap_or(false),
|
||||
browser_running: json["browserRunning"].as_bool().unwrap_or(false),
|
||||
active_tabs: json["activeTabs"].as_u64().unwrap_or(0) as u32,
|
||||
active_sessions: 1,
|
||||
});
|
||||
}
|
||||
Ok(HealthResponse {
|
||||
ok: true,
|
||||
engine: "camoufox".to_string(),
|
||||
@@ -362,25 +498,70 @@ impl BrowserClient {
|
||||
active_sessions: 0,
|
||||
})
|
||||
}
|
||||
Err(_e) => Ok(HealthResponse {
|
||||
ok: false,
|
||||
engine: "camoufox".to_string(),
|
||||
browser_connected: false,
|
||||
browser_running: false,
|
||||
active_tabs: 0,
|
||||
active_sessions: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Health check failed: {}", e);
|
||||
Ok(HealthResponse {
|
||||
ok: false,
|
||||
engine: "camoufox".to_string(),
|
||||
browser_connected: false,
|
||||
browser_running: false,
|
||||
active_tabs: 0,
|
||||
active_sessions: 0,
|
||||
})
|
||||
Transport::UnixSocket { socket_path, .. } => {
|
||||
if !socket_path.exists() {
|
||||
return Ok(HealthResponse {
|
||||
ok: false,
|
||||
engine: "camoufox".to_string(),
|
||||
browser_connected: false,
|
||||
browser_running: false,
|
||||
active_tabs: 0,
|
||||
active_sessions: 0,
|
||||
});
|
||||
}
|
||||
|
||||
match self.send_command("tab_list", |_| {}).await {
|
||||
Ok(resp) => match self.check_response(resp) {
|
||||
Ok(data) => {
|
||||
let tabs = data["tabs"].as_array().map(|a| a.len() as u32).unwrap_or(0);
|
||||
Ok(HealthResponse {
|
||||
ok: true,
|
||||
engine: "camoufox".to_string(),
|
||||
browser_connected: true,
|
||||
browser_running: true,
|
||||
active_tabs: tabs,
|
||||
active_sessions: 1,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Health check daemon error: {}", e);
|
||||
Ok(HealthResponse {
|
||||
ok: true,
|
||||
engine: "camoufox".to_string(),
|
||||
browser_connected: false,
|
||||
browser_running: false,
|
||||
active_tabs: 0,
|
||||
active_sessions: 0,
|
||||
})
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
debug!("Health check failed: {}", e);
|
||||
Ok(HealthResponse {
|
||||
ok: false,
|
||||
engine: "camoufox".to_string(),
|
||||
browser_connected: false,
|
||||
browser_running: false,
|
||||
active_tabs: 0,
|
||||
active_sessions: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Navigation ─────────────────────────────────────────────────
|
||||
|
||||
/// Navigate to a URL. The daemon auto-launches the browser on first call.
|
||||
/// Navigate to a URL.
|
||||
pub async fn navigate(&self, url: &str) -> BrowserResult<NavigationResult> {
|
||||
let resp = self.send_command("navigate", |cmd| {
|
||||
cmd.url = Some(url.to_string());
|
||||
@@ -412,7 +593,7 @@ impl BrowserClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh the current page.
|
||||
/// Refresh.
|
||||
pub async fn refresh(&self) -> BrowserResult<NavigationResult> {
|
||||
let resp = self.send_command("reload", |_| {}).await?;
|
||||
let data = self.check_response(resp)?;
|
||||
@@ -422,9 +603,7 @@ impl BrowserClient {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Snapshot ───────────────────────────────────────────────────
|
||||
|
||||
/// Get an accessibility snapshot of the current page.
|
||||
/// Get page snapshot.
|
||||
pub async fn snapshot(&self, interactive: bool) -> BrowserResult<PageSnapshot> {
|
||||
let resp = self.send_command("snapshot", |cmd| {
|
||||
cmd.interactive = Some(interactive);
|
||||
@@ -432,9 +611,8 @@ impl BrowserClient {
|
||||
}).await?;
|
||||
let data = self.check_response(resp)?;
|
||||
|
||||
let raw_snapshot = data["snapshot"].as_str().unwrap_or("").to_string();
|
||||
Ok(PageSnapshot {
|
||||
snapshot: raw_snapshot,
|
||||
snapshot: data["snapshot"].as_str().unwrap_or("").to_string(),
|
||||
url: data["url"].as_str().unwrap_or("").to_string(),
|
||||
title: data["title"].as_str().unwrap_or("").to_string(),
|
||||
screenshot: data["screenshot"].as_str().map(|s| s.to_string()),
|
||||
@@ -442,9 +620,7 @@ impl BrowserClient {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Interaction ────────────────────────────────────────────────
|
||||
|
||||
/// Click an element by its ref (e.g., "e1").
|
||||
/// Click an element.
|
||||
pub async fn click(&self, element_ref: &str) -> BrowserResult<InteractionResult> {
|
||||
let resp = self.send_command("click", |cmd| {
|
||||
cmd.selector = Some(format!("[ref=\"{}\"]", element_ref));
|
||||
@@ -456,7 +632,7 @@ impl BrowserClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Type text into an element and optionally press Enter.
|
||||
/// Type text.
|
||||
pub async fn type_text(&self, element_ref: &str, text: &str, press_enter: bool) -> BrowserResult<InteractionResult> {
|
||||
let resp = self.send_command("fill", |cmd| {
|
||||
cmd.selector = Some(format!("[ref=\"{}\"]", element_ref));
|
||||
@@ -474,7 +650,7 @@ impl BrowserClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Press a keyboard key.
|
||||
/// Press a key.
|
||||
pub async fn press(&self, key: &str) -> BrowserResult<()> {
|
||||
let resp = self.send_command("press", |cmd| {
|
||||
cmd.text = Some(key.to_string());
|
||||
@@ -483,7 +659,7 @@ impl BrowserClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scroll the page.
|
||||
/// Scroll.
|
||||
pub async fn scroll(&self, direction: &str, amount: u32) -> BrowserResult<()> {
|
||||
let resp = self.send_command("scroll", |cmd| {
|
||||
cmd.direction = Some(direction.to_string());
|
||||
@@ -493,9 +669,7 @@ impl BrowserClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Tab Management ─────────────────────────────────────────────
|
||||
|
||||
/// List all open tabs.
|
||||
/// List tabs.
|
||||
pub async fn list_tabs(&self) -> BrowserResult<Vec<TabInfo>> {
|
||||
let resp = self.send_command("tab_list", |_| {}).await?;
|
||||
let data = self.check_response(resp)?;
|
||||
@@ -514,7 +688,7 @@ impl BrowserClient {
|
||||
Ok(tabs)
|
||||
}
|
||||
|
||||
/// Create a new tab with an optional URL.
|
||||
/// New tab.
|
||||
pub async fn new_tab(&self, url: Option<&str>) -> BrowserResult<TabInfo> {
|
||||
let resp = self.send_command("tab_new", |cmd| {
|
||||
cmd.url = url.map(|s| s.to_string());
|
||||
@@ -529,7 +703,7 @@ impl BrowserClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Switch to a tab by index.
|
||||
/// Switch tab.
|
||||
pub async fn switch_tab(&self, index: u32) -> BrowserResult<TabInfo> {
|
||||
let resp = self.send_command("tab_switch", |cmd| {
|
||||
cmd.index = Some(index);
|
||||
@@ -543,21 +717,19 @@ impl BrowserClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Close the browser.
|
||||
/// Close browser.
|
||||
pub async fn close_browser(&self) -> BrowserResult<()> {
|
||||
let resp = self.send_command("close", |_| {}).await;
|
||||
match resp {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
warn!("Close browser returned: {}", e);
|
||||
warn!("Close returned: {}", e);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Search Macros ──────────────────────────────────────────────
|
||||
|
||||
/// Execute a search using a built-in daemon macro.
|
||||
/// Search via macro.
|
||||
pub async fn search(&self, macro_name: &str, query: &str) -> BrowserResult<NavigationResult> {
|
||||
let resp = self.send_command("navigate", |cmd| {
|
||||
cmd.macro_name = Some(macro_name.to_string());
|
||||
@@ -571,8 +743,6 @@ impl BrowserClient {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
fn extract_optional_snapshot(&self, data: &Value) -> Option<PageSnapshot> {
|
||||
data["snapshot"].as_str().map(|snap_text| PageSnapshot {
|
||||
snapshot: snap_text.to_string(),
|
||||
@@ -583,7 +753,7 @@ impl BrowserClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait for a selector or text to appear.
|
||||
/// Wait.
|
||||
pub async fn wait(&self, timeout_ms: u64) -> BrowserResult<()> {
|
||||
let resp = self.send_command("wait", |cmd| {
|
||||
cmd.selector = Some(format!("{}", timeout_ms));
|
||||
@@ -599,15 +769,12 @@ impl Default for BrowserClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Connection / Daemon Management ────────────────────────────────
|
||||
|
||||
/// Check if the daemon socket is reachable.
|
||||
pub async fn check_daemon_socket(session: &str) -> bool {
|
||||
let path = default_socket_path(session);
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
// Quick connect test
|
||||
match tokio::time::timeout(Duration::from_secs(1), UnixStream::connect(&path)).await {
|
||||
Ok(Ok(_)) => true,
|
||||
_ => false,
|
||||
@@ -647,8 +814,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_no_daemon_running() {
|
||||
// With no daemon running, health should report not connected
|
||||
// Don't fail — just verify the shape
|
||||
let client = BrowserClient::new();
|
||||
let health = client.health().await.unwrap_or(HealthResponse {
|
||||
ok: false,
|
||||
|
||||
Reference in New Issue
Block a user