Files
gyandoot-v2/crates/gyandoot-browser/src/client.rs
Tara Menon 8c9a32075d Slash commands (/quit, /help) + zero compiler warnings
1. /quit and /help commands restored:
   - /quit: exits the interactive loop
   - /help: shows formatted help box with available commands & tips
   - Unknown commands get a friendly error pointing to /help
   - Slash commands intercepted before LLM processing (no token waste)

2. Zero warnings across entire workspace (13 files fixed):
   - Fixed ambiguous glob re-exports in gyandoot-core
   - Added #[allow(dead_code)] on intentional dead fields
   - Fixed unused imports, variables, and macros
   - Fixed unused assignments in subagents
   - cleaned up service.rs, profile, kanban warnings

install.sh now produces a completely clean build with zero warnings.
2026-06-12 12:15:01 +05:30

663 lines
23 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`.
//!
//! 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.
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};
// ─── Constants ──────────────────────────────────────────────────────────
/// 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 ──────────────────────────────────────────────────────────────
#[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,
}
pub type BrowserResult<T> = Result<T, BrowserError>;
// ─── IPC Protocol Types ─────────────────────────────────────────────────
/// 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 returned by the daemon.
#[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 from the daemon.
#[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 (click/type).
#[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,
}
// ─── BrowserClient ──────────────────────────────────────────────────────
/// Client that talks to the camoufox-browser daemon over Unix socket.
#[derive(Debug, Clone)]
pub struct BrowserClient {
/// Session name.
session: String,
/// Socket path.
socket_path: PathBuf,
/// Timeout for daemon responses.
timeout: Duration,
}
impl BrowserClient {
/// Create a new client for the default session.
pub fn new() -> Self {
Self {
session: DEFAULT_SESSION.to_string(),
socket_path: default_socket_path(DEFAULT_SESSION),
timeout: Duration::from_secs(30),
}
}
/// 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);
self
}
/// Create a client with a custom socket path.
pub fn with_socket(mut self, path: &str) -> Self {
self.socket_path = PathBuf::from(path);
self
}
/// Get the socket path.
pub fn socket_path(&self) -> &PathBuf {
&self.socket_path
}
/// Get the session name.
pub fn session(&self) -> &str {
&self.session
}
// ─── Low-level IPC ──────────────────────────────────────────────
/// Send a command to the daemon and receive a response.
async fn send_command(&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 command: {}", e)))?;
debug!("camoufox IPC → {} (action={})", payload, action);
// Connect and send
let stream = tokio::time::timeout(
Duration::from_secs(5),
UnixStream::connect(&self.socket_path),
)
.await
.map_err(|_| BrowserError::SocketNotFound(self.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.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::Io)?;
if line.is_empty() {
return Err(BrowserError::Daemon("Empty response from daemon".to_string()));
}
let response: DaemonResponse = serde_json::from_str(line.trim())
.map_err(|e| BrowserError::Daemon(format!("Parse response: {} — payload: {}", e, line.trim())))?;
debug!("camoufox IPC ← {:?}", response);
Ok(response)
}
/// 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)
}
// ─── Health ─────────────────────────────────────────────────────
/// Check daemon health by sending a quick snapshot command.
/// The daemon has no dedicated /health endpoint — we check socket existence + connectivity.
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,
});
}
// 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);
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.
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 the current page.
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(),
})
}
// ─── Snapshot ───────────────────────────────────────────────────
/// Get an accessibility snapshot of the current page.
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)?;
let raw_snapshot = data["snapshot"].as_str().unwrap_or("").to_string();
Ok(PageSnapshot {
snapshot: raw_snapshot,
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(),
})
}
// ─── Interaction ────────────────────────────────────────────────
/// Click an element by its ref (e.g., "e1").
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 into an element and optionally press Enter.
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 keyboard 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 the page.
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(())
}
// ─── Tab Management ─────────────────────────────────────────────
/// List all open 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)
}
/// Create a new tab with an optional URL.
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 to a tab by index.
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 the 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);
Ok(())
}
}
}
// ─── Search Macros ──────────────────────────────────────────────
/// Execute a search using a built-in daemon 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());
cmd.query = Some(query.to_string());
cmd.wait = Some(3000);
}).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(),
})
}
// ─── Helpers ────────────────────────────────────────────────────
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 for a selector or text to appear.
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(())
}
}
impl Default for BrowserClient {
fn default() -> Self {
Self::new()
}
}
// ─── 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,
}
}
#[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() {
// 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,
engine: "camoufox".to_string(),
browser_connected: false,
browser_running: false,
active_tabs: 0,
active_sessions: 0,
});
assert_eq!(health.engine, "camoufox");
}
}