Bug 1 — writer.shutdown() kills Node socket before response: Rust called shutdown() on the write half immediately after writing the command. This sent EOF to the Node.js daemon, whose safeWrite() checks socket.destroyed and silently bails. Fixed by dropping writer instead. Bug 2 — daemon has zero macro support: The daemon's execute() never handles macro_name/query. Only url matters for the navigate action. Fixed search() to resolve all 12 macros (@google_search, @youtube_search, etc.) to actual search URLs in Rust before sending navigate with cmd.url set.
896 lines
31 KiB
Rust
896 lines
31 KiB
Rust
//! Daemon IPC client for camoufox-browser.
|
|
//!
|
|
//! Communicates with the camoufox-browser daemon via JSON-line protocol
|
|
//! over a Unix socket at `~/.camoufox-browser/<session>.sock`.
|
|
//!
|
|
//! 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;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::net::UnixStream;
|
|
use tracing::{debug, warn};
|
|
|
|
/// Default session name.
|
|
pub const DEFAULT_SESSION: &str = "default";
|
|
|
|
/// Default daemon socket directory inside `~/.camoufox-browser/`.
|
|
fn default_socket_dir() -> PathBuf {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
|
.join(".camoufox-browser")
|
|
}
|
|
|
|
/// Default daemon socket path.
|
|
pub fn default_socket_path(session: &str) -> PathBuf {
|
|
default_socket_dir().join(format!("{}.sock", session))
|
|
}
|
|
|
|
/// Error types.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum BrowserError {
|
|
#[error("IO error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
|
|
#[error("Daemon error: {0}")]
|
|
Daemon(String),
|
|
|
|
#[error("Socket not found: {0}")]
|
|
SocketNotFound(String),
|
|
|
|
#[error("Daemon response missing 'data' field")]
|
|
MissingData,
|
|
|
|
#[error("Tab not found: {0}")]
|
|
TabNotFound(String),
|
|
|
|
#[error("Navigation failed: {0}")]
|
|
NavigationFailed(String),
|
|
|
|
#[error("Snapshot unavailable")]
|
|
SnapshotUnavailable,
|
|
|
|
#[error("HTTP error: {0}")]
|
|
Http(String),
|
|
}
|
|
|
|
pub type BrowserResult<T> = Result<T, BrowserError>;
|
|
|
|
/// 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)]
|
|
struct DaemonCommand {
|
|
id: String,
|
|
action: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
query: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
macro_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
wait: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
selector: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
r#ref: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
text: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
press_enter: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
headless: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
index: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
direction: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
amount: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
interactive: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
compact: Option<bool>,
|
|
}
|
|
|
|
/// A response from the daemon.
|
|
#[derive(Debug, Deserialize)]
|
|
struct DaemonResponse {
|
|
#[allow(dead_code)]
|
|
id: String,
|
|
success: bool,
|
|
#[serde(default)]
|
|
data: Option<Value>,
|
|
#[serde(default)]
|
|
error: Option<String>,
|
|
}
|
|
|
|
/// Health status.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct HealthResponse {
|
|
pub ok: bool,
|
|
pub engine: String,
|
|
#[serde(default)]
|
|
pub browser_connected: bool,
|
|
#[serde(default)]
|
|
pub browser_running: bool,
|
|
#[serde(default)]
|
|
pub active_tabs: u32,
|
|
#[serde(default)]
|
|
pub active_sessions: u32,
|
|
}
|
|
|
|
/// Navigation result.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct NavigationResult {
|
|
pub url: String,
|
|
pub title: String,
|
|
}
|
|
|
|
/// Snapshot of a page (accessibility tree).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PageSnapshot {
|
|
pub snapshot: String,
|
|
#[serde(default)]
|
|
pub url: String,
|
|
#[serde(default)]
|
|
pub title: String,
|
|
#[serde(default)]
|
|
pub screenshot: Option<String>,
|
|
#[serde(default)]
|
|
pub elements: std::collections::HashMap<String, ElementInfo>,
|
|
}
|
|
|
|
/// Element info in a snapshot.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ElementInfo {
|
|
#[serde(default)]
|
|
pub tag: String,
|
|
#[serde(default)]
|
|
pub text: String,
|
|
#[serde(default)]
|
|
pub href: Option<String>,
|
|
#[serde(default)]
|
|
pub interactive: bool,
|
|
}
|
|
|
|
impl PageSnapshot {
|
|
pub fn extract_refs(&self) -> Vec<String> {
|
|
let mut refs = Vec::new();
|
|
for line in self.snapshot.lines() {
|
|
if let Some(start) = line.find("[e") {
|
|
let after = &line[start + 2..];
|
|
if let Some(end) = after.find(']') {
|
|
let ref_str = line[start..=start + 1 + end].to_string();
|
|
if !refs.contains(&ref_str) {
|
|
refs.push(ref_str);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
refs.sort();
|
|
refs
|
|
}
|
|
}
|
|
|
|
/// Interaction result.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct InteractionResult {
|
|
#[serde(default)]
|
|
pub ok: bool,
|
|
#[serde(default)]
|
|
pub snapshot: Option<PageSnapshot>,
|
|
}
|
|
|
|
/// Tab information.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TabInfo {
|
|
pub index: u32,
|
|
pub title: String,
|
|
pub url: String,
|
|
pub active: bool,
|
|
}
|
|
|
|
/// Client that talks to camoufox-browser over Unix socket or HTTP.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BrowserClient {
|
|
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 Unix socket session.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
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 {
|
|
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 {
|
|
match &mut self.transport {
|
|
Transport::UnixSocket { socket_path, .. } => {
|
|
*socket_path = PathBuf::from(path);
|
|
}
|
|
Transport::Http { .. } => {} // no-op for HTTP
|
|
}
|
|
self
|
|
}
|
|
|
|
/// 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.transport.session()
|
|
}
|
|
|
|
/// 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.
|
|
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,
|
|
};
|
|
build(&mut cmd);
|
|
|
|
let payload = serde_json::to_string(&cmd)
|
|
.map_err(|e| BrowserError::Daemon(format!("Serialize: {}", e)))?;
|
|
|
|
debug!("camoufox IPC → {} (action={})", payload, action);
|
|
|
|
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(&socket_path),
|
|
)
|
|
.await
|
|
.map_err(|_| BrowserError::SocketNotFound(socket_path.display().to_string()))?
|
|
.map_err(BrowserError::Io)?;
|
|
|
|
let (reader, mut writer) = stream.into_split();
|
|
writer.write_all(format!("{}\n", payload).as_bytes()).await.map_err(BrowserError::Io)?;
|
|
// Don't call writer.shutdown() — it sends EOF to the Node.js daemon,
|
|
// which causes the socket to close before the async response arrives.
|
|
// The daemon's safeWrite() checks socket.destroyed and bails silently.
|
|
// The socket pair (reader/writer) will be dropped after we read the response.
|
|
drop(writer);
|
|
|
|
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".into()))?
|
|
.map_err(BrowserError::Io)?;
|
|
|
|
if line.is_empty() {
|
|
return Err(BrowserError::Daemon("Empty response".into()));
|
|
}
|
|
|
|
let response: DaemonResponse = serde_json::from_str(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 {
|
|
let err = resp.error.unwrap_or_else(|| "Unknown error".to_string());
|
|
return Err(BrowserError::Daemon(err));
|
|
}
|
|
resp.data.ok_or(BrowserError::MissingData)
|
|
}
|
|
|
|
/// Check daemon health.
|
|
pub async fn health(&self) -> BrowserResult<HealthResponse> {
|
|
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()))?;
|
|
|
|
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(),
|
|
browser_connected: false,
|
|
browser_running: false,
|
|
active_tabs: 0,
|
|
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,
|
|
}),
|
|
}
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(NavigationResult {
|
|
url: data["url"].as_str().unwrap_or(url).to_string(),
|
|
title: data["title"].as_str().unwrap_or("").to_string(),
|
|
})
|
|
}
|
|
|
|
/// Navigate back.
|
|
pub async fn go_back(&self) -> BrowserResult<NavigationResult> {
|
|
let resp = self.send_command("back", |_| {}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(NavigationResult {
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
title: "".to_string(),
|
|
})
|
|
}
|
|
|
|
/// Navigate forward.
|
|
pub async fn go_forward(&self) -> BrowserResult<NavigationResult> {
|
|
let resp = self.send_command("forward", |_| {}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(NavigationResult {
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
title: "".to_string(),
|
|
})
|
|
}
|
|
|
|
/// Refresh.
|
|
pub async fn refresh(&self) -> BrowserResult<NavigationResult> {
|
|
let resp = self.send_command("reload", |_| {}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(NavigationResult {
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
title: "".to_string(),
|
|
})
|
|
}
|
|
|
|
/// Get page snapshot.
|
|
pub async fn snapshot(&self, interactive: bool) -> BrowserResult<PageSnapshot> {
|
|
let resp = self.send_command("snapshot", |cmd| {
|
|
cmd.interactive = Some(interactive);
|
|
cmd.compact = Some(false);
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
|
|
Ok(PageSnapshot {
|
|
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()),
|
|
elements: std::collections::HashMap::new(),
|
|
})
|
|
}
|
|
|
|
/// 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));
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(InteractionResult {
|
|
ok: true,
|
|
snapshot: self.extract_optional_snapshot(&data),
|
|
})
|
|
}
|
|
|
|
/// 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));
|
|
cmd.text = Some(text.to_string());
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
|
|
if press_enter {
|
|
self.press("Enter").await?;
|
|
}
|
|
|
|
Ok(InteractionResult {
|
|
ok: true,
|
|
snapshot: self.extract_optional_snapshot(&data),
|
|
})
|
|
}
|
|
|
|
/// Press a key.
|
|
pub async fn press(&self, key: &str) -> BrowserResult<()> {
|
|
let resp = self.send_command("press", |cmd| {
|
|
cmd.text = Some(key.to_string());
|
|
}).await?;
|
|
self.check_response(resp)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Scroll.
|
|
pub async fn scroll(&self, direction: &str, amount: u32) -> BrowserResult<()> {
|
|
let resp = self.send_command("scroll", |cmd| {
|
|
cmd.direction = Some(direction.to_string());
|
|
cmd.amount = Some(amount);
|
|
}).await?;
|
|
self.check_response(resp)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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)?;
|
|
|
|
let tabs = data["tabs"].as_array()
|
|
.map(|arr| {
|
|
arr.iter().map(|t| TabInfo {
|
|
index: t["index"].as_u64().unwrap_or(0) as u32,
|
|
title: t["title"].as_str().unwrap_or("").to_string(),
|
|
url: t["url"].as_str().unwrap_or("").to_string(),
|
|
active: t["active"].as_bool().unwrap_or(false),
|
|
}).collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
Ok(tabs)
|
|
}
|
|
|
|
/// 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());
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
let idx = data["index"].as_u64().unwrap_or(0) as u32;
|
|
Ok(TabInfo {
|
|
index: idx,
|
|
title: String::new(),
|
|
url: url.unwrap_or("about:blank").to_string(),
|
|
active: true,
|
|
})
|
|
}
|
|
|
|
/// Switch tab.
|
|
pub async fn switch_tab(&self, index: u32) -> BrowserResult<TabInfo> {
|
|
let resp = self.send_command("tab_switch", |cmd| {
|
|
cmd.index = Some(index);
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(TabInfo {
|
|
index,
|
|
title: data["title"].as_str().unwrap_or("").to_string(),
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
active: true,
|
|
})
|
|
}
|
|
|
|
/// Close browser.
|
|
pub async fn close_browser(&self) -> BrowserResult<()> {
|
|
let resp = self.send_command("close", |_| {}).await;
|
|
match resp {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => {
|
|
warn!("Close returned: {}", e);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Search via macro — resolves macro names to actual search URLs
|
|
/// since the camoufox-browser daemon does not handle macros internally.
|
|
pub async fn search(&self, macro_name: &str, query: &str) -> BrowserResult<NavigationResult> {
|
|
let url = match macro_name {
|
|
"@google_search" | "google" => {
|
|
format!("https://www.google.com/search?q={}&hl=en", urlencoding(query))
|
|
}
|
|
"@youtube_search" | "youtube" => {
|
|
format!("https://www.youtube.com/results?search_query={}", urlencoding(query))
|
|
}
|
|
"@amazon_search" | "amazon" => {
|
|
format!("https://www.amazon.com/s?k={}", urlencoding(query))
|
|
}
|
|
"@reddit_search" | "reddit" => {
|
|
format!("https://www.reddit.com/search/?q={}", urlencoding(query))
|
|
}
|
|
"@wikipedia_search" | "wikipedia" => {
|
|
format!("https://en.wikipedia.org/w/index.php?search={}", urlencoding(query))
|
|
}
|
|
"@twitter_search" | "@x_search" | "twitter" | "x" => {
|
|
format!("https://x.com/search?q={}&src=typed_query", urlencoding(query))
|
|
}
|
|
"@yelp_search" | "yelp" => {
|
|
format!("https://www.yelp.com/search?find_desc={}", urlencoding(query))
|
|
}
|
|
"@spotify_search" | "spotify" => {
|
|
format!("https://open.spotify.com/search/{}", urlencoding(query))
|
|
}
|
|
"@linkedin_search" | "linkedin" => {
|
|
format!("https://www.linkedin.com/search/results/all/?keywords={}", urlencoding(query))
|
|
}
|
|
"@instagram_search" | "instagram" => {
|
|
format!("https://www.instagram.com/explore/search/keyword/?q={}", urlencoding(query))
|
|
}
|
|
"@tiktok_search" | "tiktok" => {
|
|
format!("https://www.tiktok.com/search?q={}", urlencoding(query))
|
|
}
|
|
"@twitch_search" | "twitch" => {
|
|
format!("https://www.twitch.tv/search?term={}", urlencoding(query))
|
|
}
|
|
// If it looks like a URL already, use it as-is
|
|
s if s.starts_with("http://") || s.starts_with("https://") => s.to_string(),
|
|
// Default: Google search
|
|
_ => {
|
|
warn!("Unknown macro '{}', falling back to Google search", macro_name);
|
|
format!("https://www.google.com/search?q={}&hl=en", urlencoding(query))
|
|
}
|
|
};
|
|
|
|
let resp = self.send_command("navigate", |cmd| {
|
|
cmd.url = Some(url);
|
|
cmd.wait = Some(5000);
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(NavigationResult {
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
title: data["title"].as_str().unwrap_or("").to_string(),
|
|
})
|
|
}
|
|
|
|
fn extract_optional_snapshot(&self, data: &Value) -> Option<PageSnapshot> {
|
|
data["snapshot"].as_str().map(|snap_text| PageSnapshot {
|
|
snapshot: snap_text.to_string(),
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
title: data["title"].as_str().unwrap_or("").to_string(),
|
|
screenshot: None,
|
|
elements: std::collections::HashMap::new(),
|
|
})
|
|
}
|
|
|
|
/// Wait.
|
|
pub async fn wait(&self, timeout_ms: u64) -> BrowserResult<()> {
|
|
let resp = self.send_command("wait", |cmd| {
|
|
cmd.selector = Some(format!("{}", timeout_ms));
|
|
}).await?;
|
|
self.check_response(resp)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// URL-encode a string for use in a search query.
|
|
fn urlencoding(input: &str) -> String {
|
|
// Simple URL encoding — replaces spaces with + and encodes special chars
|
|
let mut result = String::with_capacity(input.len());
|
|
for byte in input.bytes() {
|
|
match byte {
|
|
b' ' => result.push('+'),
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
result.push(byte as char);
|
|
}
|
|
_ => {
|
|
result.push_str(&format!("%{:02X}", byte));
|
|
}
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
impl Default for BrowserClient {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
match tokio::time::timeout(Duration::from_secs(1), UnixStream::connect(&path)).await {
|
|
Ok(Ok(_)) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_default_socket_path() {
|
|
let path = default_socket_path("default");
|
|
assert!(path.to_string_lossy().ends_with(".camoufox-browser/default.sock"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_refs() {
|
|
let snap = PageSnapshot {
|
|
snapshot: "- heading \"Hello\" [level=1]\n- link \"Click\" [e1]\n- paragraph: text [e2]\n".into(),
|
|
url: String::new(),
|
|
title: String::new(),
|
|
screenshot: None,
|
|
elements: std::collections::HashMap::new(),
|
|
};
|
|
let refs = snap.extract_refs();
|
|
assert!(refs.contains(&"[e1]".to_string()));
|
|
assert!(refs.contains(&"[e2]".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_navigation_result_deserialize() {
|
|
let json = r#"{"url":"https://example.com","title":"Example"}"#;
|
|
let result: NavigationResult = serde_json::from_str(json).unwrap();
|
|
assert_eq!(result.url, "https://example.com");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_health_no_daemon_running() {
|
|
let client = BrowserClient::new();
|
|
let health = client.health().await.unwrap_or(HealthResponse {
|
|
ok: false,
|
|
engine: "camoufox".to_string(),
|
|
browser_connected: false,
|
|
browser_running: false,
|
|
active_tabs: 0,
|
|
active_sessions: 0,
|
|
});
|
|
assert_eq!(health.engine, "camoufox");
|
|
}
|
|
} |