Option C: Rewrite gyandoot-browser for camoufox-browser@0.1.0 daemon IPC
Complete rewrite removing old HTTP REST client. The latest camoufox-browser npm package (v0.1.0) uses Unix socket JSON-line IPC, not HTTP REST on :9377. Changes: - gyandoot-browser/client.rs: Full daemon IPC via Unix socket (~/.camoufox-browser/*.sock) - JSON-line protocol matching ensureDaemonRunning + daemon.js - Auto-launches browser on first command (daemon manages lifecycle) - Supports navigate, click, type, scroll, snapshot, search macros, tab management - Proper HeathResponse, NavigationResult, PageSnapshot types - gyandoot-browser/lib.rs: Exports new client API (no SearchMacro enum, no SessionManager) - gyandoot-browser/Cargo.toml: reqwest -> tokio Unix socket deps - Removed: macros.rs, session.rs, snapshot.rs (all merged into client.rs) - browser_manager.rs: Triggers daemon via npx, waits for Unix socket - browser_tools.rs: Simplified API (no tab_id, no create_tab/close_tab) Fix: camoufox-browser process exited with status 1 error on fresh install
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -697,8 +697,8 @@ version = "2.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"dirs",
|
||||||
"gyandoot-core",
|
"gyandoot-core",
|
||||||
"reqwest",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
|||||||
@@ -1,174 +1,145 @@
|
|||||||
//! BrowserManager — Auto-start, health-check, and lifecycle for Camoufox.
|
//! BrowserManager — Start, connect, and manage lifecycle of the camoufox-browser daemon.
|
||||||
|
//!
|
||||||
|
//! The `camoufox-browser@0.1.0` CLI auto-starts a background daemon
|
||||||
|
//! (Node.js process) on any command. The daemon listens on a Unix socket at
|
||||||
|
//! `~/.camoufox-browser/<session>.sock` and auto-launches the camoufox browser
|
||||||
|
//! on the first browser-dependent command.
|
||||||
//!
|
//!
|
||||||
//! On `gyandoot run`, the BrowserManager:
|
//! On `gyandoot run`, the BrowserManager:
|
||||||
//! 1. Checks if Camoufox is already running (health endpoint)
|
//! 1. Checks if the daemon socket already exists and is connected
|
||||||
//! 2. If not, starts it as a background child process
|
//! 2. If not, runs a quick CLI command to trigger daemon auto-start
|
||||||
//! 3. Waits for it to become healthy (up to 30 seconds)
|
//! 3. Waits for the Unix socket to appear (up to 30 seconds)
|
||||||
//! 4. Returns a ready-to-use `BrowserClient`
|
//! 4. Returns a ready-to-use `BrowserClient`
|
||||||
//!
|
|
||||||
//! Camoufox is installed globally via `npx camoufox-browser` by `install.sh`.
|
|
||||||
|
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tracing::{info, warn, error};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use gyandoot_browser::BrowserClient;
|
use gyandoot_browser::{check_daemon_socket, default_socket_path, BrowserClient};
|
||||||
|
|
||||||
/// Default Camoufox API endpoint.
|
/// Default session name.
|
||||||
const CAMOFOX_URL: &str = "http://localhost:9377";
|
const DEFAULT_SESSION: &str = "default";
|
||||||
|
|
||||||
/// Maximum time to wait for Camoufox to become healthy.
|
/// Maximum time to wait for the daemon socket to appear.
|
||||||
const STARTUP_TIMEOUT_SECS: u64 = 30;
|
const DAEMON_STARTUP_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
/// Health-check retry interval.
|
/// Poll interval while waiting for daemon to start.
|
||||||
const HEALTH_RETRY_MS: u64 = 500;
|
const DAEMON_POLL_MS: u64 = 500;
|
||||||
|
|
||||||
/// Result of browser initialization.
|
/// Result of browser initialization.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BrowserInitResult {
|
pub enum BrowserInitResult {
|
||||||
/// Browser is running and ready.
|
/// Browser daemon is running and client is ready.
|
||||||
Ready(BrowserClient),
|
Ready(BrowserClient),
|
||||||
/// Could not start browser (user needs to install/start manually).
|
/// Could not start daemon.
|
||||||
Unavailable(String),
|
Unavailable(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the Camoufox browser: check health, start if needed.
|
/// Initialize the camoufox-browser daemon and return a ready client.
|
||||||
///
|
|
||||||
/// Returns a `BrowserInitResult` — either a ready client or an explanation.
|
|
||||||
pub async fn init_browser() -> BrowserInitResult {
|
pub async fn init_browser() -> BrowserInitResult {
|
||||||
let client = BrowserClient::new();
|
let session = DEFAULT_SESSION;
|
||||||
|
let socket_path = default_socket_path(session);
|
||||||
|
|
||||||
// 1. Quick health check — is it already running?
|
// 1. Quick check — is the daemon already running?
|
||||||
match try_health_check(&client).await {
|
if check_daemon_socket(session).await {
|
||||||
Ok(true) => {
|
info!("Camoufox daemon already running on socket: {}", socket_path.display());
|
||||||
info!("Camoufox already running on localhost:9377");
|
let client = BrowserClient::new().with_session(session);
|
||||||
return BrowserInitResult::Ready(client);
|
return BrowserInitResult::Ready(client);
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
|
||||||
info!("Camoufox health check returned not-ready, attempting to start...");
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
info!("Camoufox not running, attempting to start...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try to start Camoufox as a background process
|
info!("Camoufox daemon not running, triggering startup...");
|
||||||
match start_camoufox().await {
|
|
||||||
|
// 2. Trigger daemon auto-start via CLI
|
||||||
|
// The CLI calls `ensureDaemonRunning()` internally which spawns the daemon
|
||||||
|
match trigger_daemon_start(session).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// 3. Wait for it to become healthy
|
// 3. Wait for socket to appear
|
||||||
info!("Waiting for Camoufox to become healthy...");
|
info!("Waiting for daemon socket at {}...", socket_path.display());
|
||||||
match wait_for_health(&client).await {
|
match wait_for_socket(session).await {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
info!("Camoufox started successfully");
|
info!("Camoufox daemon started successfully");
|
||||||
return BrowserInitResult::Ready(client);
|
let client = BrowserClient::new().with_session(session);
|
||||||
|
// Brief pause for daemon to fully initialize
|
||||||
|
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||||
|
BrowserInitResult::Ready(client)
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"Camoufox process started but did not become healthy within {}s. \
|
"Daemon did not start within {}s — socket not found at {}",
|
||||||
Check the logs at ~/.gyandoot/camoufox.log",
|
DAEMON_STARTUP_TIMEOUT_SECS,
|
||||||
STARTUP_TIMEOUT_SECS
|
socket_path.display()
|
||||||
);
|
);
|
||||||
warn!("{}", msg);
|
warn!("{}", msg);
|
||||||
return BrowserInitResult::Unavailable(msg);
|
BrowserInitResult::Unavailable(msg)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("Camoufox health-check error after startup: {}", e);
|
let msg = format!("Daemon socket error: {}", e);
|
||||||
warn!("{}", msg);
|
warn!("{}", msg);
|
||||||
return BrowserInitResult::Unavailable(msg);
|
BrowserInitResult::Unavailable(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"Could not start Camoufox: {}\n\n\
|
"Could not start camoufox daemon: {}\n\n\
|
||||||
Install it manually:\n npm install -g camoufox-browser\n \
|
Make sure Node.js v20+ and the package are installed:\n \
|
||||||
|
npm install -g camoufox-browser\n \
|
||||||
npx camoufox-browser@latest install\n\n\
|
npx camoufox-browser@latest install\n\n\
|
||||||
Then start before running gyandoot:\n npx camoufox-browser start &",
|
Then try again.",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
warn!("{}", msg);
|
warn!("{}", msg);
|
||||||
return BrowserInitResult::Unavailable(msg);
|
BrowserInitResult::Unavailable(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quick health check — returns true if browser is connected and running.
|
/// Trigger daemon startup by running a quick CLI command.
|
||||||
async fn try_health_check(client: &BrowserClient) -> Result<bool, String> {
|
///
|
||||||
match tokio::time::timeout(Duration::from_secs(3), client.health()).await {
|
/// The `camoufox-browser@latest` CLI auto-starts the daemon via
|
||||||
Ok(Ok(health)) => {
|
/// `ensureDaemonRunning()` on any command. We run a harmless
|
||||||
Ok(health.browser_connected && health.browser_running)
|
/// `tab_list` which spawns the daemon in the background and
|
||||||
}
|
/// returns once complete.
|
||||||
Ok(Err(e)) => Err(e.to_string()),
|
async fn trigger_daemon_start(session: &str) -> Result<(), String> {
|
||||||
Err(_) => Err("timeout".to_string()),
|
// Create log directory for any stderr output
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start Camoufox as a background child process.
|
|
||||||
async fn start_camoufox() -> Result<(), String> {
|
|
||||||
// Create log directory
|
|
||||||
let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
|
let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
|
||||||
let log_dir = home.join(".gyandoot");
|
let log_dir = home.join(".gyandoot");
|
||||||
std::fs::create_dir_all(&log_dir).map_err(|e| format!("Cannot create {}: {}", log_dir.display(), e))?;
|
std::fs::create_dir_all(&log_dir)
|
||||||
let log_file = log_dir.join("camoufox.log");
|
.map_err(|e| format!("Cannot create {}: {}", log_dir.display(), e))?;
|
||||||
|
let log_file = log_dir.join("camoufox-daemon-spawn.log");
|
||||||
|
|
||||||
let log = std::fs::File::create(&log_file)
|
let log = std::fs::File::create(&log_file)
|
||||||
.map_err(|e| format!("Cannot create log file {}: {}", log_file.display(), e))?;
|
.map_err(|e| format!("Cannot create log file: {}", e))?;
|
||||||
|
|
||||||
// Try npx first (npm global install), then npx direct
|
// Run a command — the CLI auto-starts the daemon as a side effect
|
||||||
let result = Command::new("npx")
|
let _output = Command::new("npx")
|
||||||
.args(&[
|
.args(&[
|
||||||
"camoufox-browser@latest",
|
"camoufox-browser@latest",
|
||||||
"start",
|
"tab_list",
|
||||||
"--port", "9377",
|
"--session", session,
|
||||||
])
|
])
|
||||||
.stdout(Stdio::from(log.try_clone().map_err(|e| e.to_string())?))
|
.stdout(Stdio::from(log.try_clone().map_err(|e| e.to_string())?))
|
||||||
.stderr(Stdio::from(log))
|
.stderr(Stdio::from(log))
|
||||||
.kill_on_drop(true)
|
.output()
|
||||||
.spawn();
|
.await
|
||||||
|
.map_err(|e| format!("npx failed: {}. Is Node.js installed?", e))?;
|
||||||
|
|
||||||
match result {
|
// Return immediately — socket polling in the caller will detect the daemon
|
||||||
Ok(mut child) => {
|
|
||||||
// Detach — let it run in background
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let status = child.wait().await;
|
|
||||||
if let Ok(s) = status {
|
|
||||||
if !s.success() {
|
|
||||||
warn!("Camoufox process exited with status: {}", s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Give it a moment to bind
|
|
||||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// npx not available — try directly via node_modules
|
|
||||||
Err(format!("npx failed: {}. Is Node.js installed?", e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll the health endpoint until the browser is ready or timeout.
|
/// Poll for the daemon Unix socket to appear.
|
||||||
async fn wait_for_health(client: &BrowserClient) -> Result<bool, String> {
|
async fn wait_for_socket(session: &str) -> Result<bool, String> {
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(STARTUP_TIMEOUT_SECS);
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(DAEMON_STARTUP_TIMEOUT_SECS);
|
||||||
let mut last_error = String::new();
|
|
||||||
|
|
||||||
while tokio::time::Instant::now() < deadline {
|
while tokio::time::Instant::now() < deadline {
|
||||||
match try_health_check(client).await {
|
if check_daemon_socket(session).await {
|
||||||
Ok(true) => return Ok(true),
|
return Ok(true);
|
||||||
Ok(false) => {
|
|
||||||
last_error = "browser not connected".to_string();
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
tokio::time::sleep(Duration::from_millis(DAEMON_POLL_MS)).await;
|
||||||
last_error = e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(HEALTH_RETRY_MS)).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(format!(
|
Ok(false)
|
||||||
"Timed out after {}s. Last status: {}",
|
|
||||||
STARTUP_TIMEOUT_SECS, last_error
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
@@ -3,16 +3,68 @@
|
|||||||
//! These tools are registered with the `ToolExecutor` and provide
|
//! These tools are registered with the `ToolExecutor` and provide
|
||||||
//! LLM-accessible functions for searching the web, browsing pages,
|
//! LLM-accessible functions for searching the web, browsing pages,
|
||||||
//! clicking elements, and extracting content.
|
//! clicking elements, and extracting content.
|
||||||
|
//!
|
||||||
|
//! Uses the camoufox-browser daemon protocol (Unix socket IPC).
|
||||||
|
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use gyandoot_browser::{BrowserClient, SearchMacro};
|
use gyandoot_browser::BrowserClient;
|
||||||
use gyandoot_core::ToolDefinition;
|
use gyandoot_core::ToolDefinition;
|
||||||
|
|
||||||
use crate::tools::Tool;
|
use crate::tools::Tool;
|
||||||
|
|
||||||
/// Tool for searching the web using camofox-browser search macros.
|
/// Search macro name strings sent to the camoufox daemon.
|
||||||
|
const MACRO_GOOGLE: &str = "@google_search";
|
||||||
|
const MACRO_YOUTUBE: &str = "@youtube_search";
|
||||||
|
const MACRO_AMAZON: &str = "@amazon_search";
|
||||||
|
const MACRO_REDDIT: &str = "@reddit_search";
|
||||||
|
const MACRO_WIKIPEDIA: &str = "@wikipedia_search";
|
||||||
|
const MACRO_TWITTER: &str = "@twitter_search";
|
||||||
|
const MACRO_YELP: &str = "@yelp_search";
|
||||||
|
const MACRO_SPOTIFY: &str = "@spotify_search";
|
||||||
|
const MACRO_LINKEDIN: &str = "@linkedin_search";
|
||||||
|
const MACRO_INSTAGRAM: &str = "@instagram_search";
|
||||||
|
const MACRO_TIKTOK: &str = "@tiktok_search";
|
||||||
|
const MACRO_TWITCH: &str = "@twitch_search";
|
||||||
|
|
||||||
|
fn site_to_macro(site: &str) -> &'static str {
|
||||||
|
match site {
|
||||||
|
"google" => MACRO_GOOGLE,
|
||||||
|
"youtube" => MACRO_YOUTUBE,
|
||||||
|
"amazon" => MACRO_AMAZON,
|
||||||
|
"reddit" => MACRO_REDDIT,
|
||||||
|
"wikipedia" => MACRO_WIKIPEDIA,
|
||||||
|
"twitter" | "x" => MACRO_TWITTER,
|
||||||
|
"yelp" => MACRO_YELP,
|
||||||
|
"spotify" => MACRO_SPOTIFY,
|
||||||
|
"linkedin" => MACRO_LINKEDIN,
|
||||||
|
"instagram" => MACRO_INSTAGRAM,
|
||||||
|
"tiktok" => MACRO_TIKTOK,
|
||||||
|
"twitch" => MACRO_TWITCH,
|
||||||
|
_ => MACRO_GOOGLE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn site_name(site: &str) -> &'static str {
|
||||||
|
match site {
|
||||||
|
"google" => "Google",
|
||||||
|
"youtube" => "YouTube",
|
||||||
|
"amazon" => "Amazon",
|
||||||
|
"reddit" => "Reddit",
|
||||||
|
"wikipedia" => "Wikipedia",
|
||||||
|
"twitter" | "x" => "Twitter/X",
|
||||||
|
"yelp" => "Yelp",
|
||||||
|
"spotify" => "Spotify",
|
||||||
|
"linkedin" => "LinkedIn",
|
||||||
|
"instagram" => "Instagram",
|
||||||
|
"tiktok" => "TikTok",
|
||||||
|
"twitch" => "Twitch",
|
||||||
|
_ => "Google",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tool for searching the web using camoufox daemon macros.
|
||||||
pub struct WebSearchTool {
|
pub struct WebSearchTool {
|
||||||
client: BrowserClient,
|
client: BrowserClient,
|
||||||
}
|
}
|
||||||
@@ -61,46 +113,23 @@ impl Tool for WebSearchTool {
|
|||||||
return Err(anyhow::anyhow!("query is required"));
|
return Err(anyhow::anyhow!("query is required"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let macro_name = match site.as_str() {
|
let mname = site_to_macro(&site);
|
||||||
"google" => SearchMacro::GoogleSearch,
|
let sname = site_name(&site);
|
||||||
"youtube" => SearchMacro::YoutubeSearch,
|
|
||||||
"amazon" => SearchMacro::AmazonSearch,
|
|
||||||
"reddit" => SearchMacro::RedditSearch,
|
|
||||||
"wikipedia" => SearchMacro::WikipediaSearch,
|
|
||||||
"twitter" | "x" => SearchMacro::TwitterSearch,
|
|
||||||
"yelp" => SearchMacro::YelpSearch,
|
|
||||||
"spotify" => SearchMacro::SpotifySearch,
|
|
||||||
"linkedin" => SearchMacro::LinkedinSearch,
|
|
||||||
"instagram" => SearchMacro::InstagramSearch,
|
|
||||||
"tiktok" => SearchMacro::TiktokSearch,
|
|
||||||
"twitch" => SearchMacro::TwitchSearch,
|
|
||||||
_ => SearchMacro::GoogleSearch,
|
|
||||||
};
|
|
||||||
|
|
||||||
info!("web_search: searching {} for '{}'", macro_name.site_name(), query);
|
info!("web_search: searching {} for '{}'", sname, query);
|
||||||
|
|
||||||
let session_key = format!("web_search_{}", std::time::SystemTime::now()
|
// Execute search — daemon auto-launches browser on first call
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
let result = self.client.search(mname, &query).await
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs());
|
|
||||||
|
|
||||||
let tab = self.client.create_tab("gyandoot", &session_key, None).await
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to create browser tab: {}", e))?;
|
|
||||||
let tab_id = tab.tab_id;
|
|
||||||
|
|
||||||
let result = self.client.search(&tab_id, "gyandoot", ¯o_name, &query, Some(3000)).await
|
|
||||||
.map_err(|e| anyhow::anyhow!("Search failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Search failed: {}", e))?;
|
||||||
|
|
||||||
let snap = self.client.snapshot(&tab_id, "gyandoot").await
|
// Get snapshot of results
|
||||||
|
let snap = self.client.snapshot(true).await
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?;
|
||||||
|
|
||||||
// Close the tab to free resources
|
|
||||||
let _ = self.client.close_tab(&tab_id, "gyandoot").await;
|
|
||||||
|
|
||||||
let refs = snap.extract_refs();
|
let refs = snap.extract_refs();
|
||||||
let response = format!(
|
let response = format!(
|
||||||
"## {} Search Results\n\n**Query:** {}\n**Page:** {}\n**Title:** {}\n\n**Snapshot content:**\n{}\n\n**Available element refs:** {:?}\n\n*Use `web_browse` to interact with specific results.*",
|
"## {} Search Results\n\n**Query:** {}\n**Page:** {}\n**Title:** {}\n\n**Snapshot content:**\n{}\n\n**Available element refs:** {:?}\n\n*Use `web_browse` to interact with specific results.*",
|
||||||
macro_name.site_name(),
|
sname,
|
||||||
query,
|
query,
|
||||||
result.url,
|
result.url,
|
||||||
result.title,
|
result.title,
|
||||||
@@ -112,7 +141,7 @@ impl Tool for WebSearchTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tool for browsing a specific URL using camofox-browser.
|
/// Tool for browsing a specific URL using camoufox daemon browser.
|
||||||
pub struct WebBrowseTool {
|
pub struct WebBrowseTool {
|
||||||
client: BrowserClient,
|
client: BrowserClient,
|
||||||
}
|
}
|
||||||
@@ -189,62 +218,52 @@ impl Tool for WebBrowseTool {
|
|||||||
return Err(anyhow::anyhow!("url is required"));
|
return Err(anyhow::anyhow!("url is required"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let session_key = format!("web_browse_{}", std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs());
|
|
||||||
|
|
||||||
let tab = self.client.create_tab("gyandoot", &session_key, Some(&url)).await
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to create browser tab: {}", e))?;
|
|
||||||
let tab_id = tab.tab_id;
|
|
||||||
|
|
||||||
match action.as_str() {
|
match action.as_str() {
|
||||||
"click" => {
|
"navigate" => {
|
||||||
if let Some(ref r#ref) = element_ref {
|
self.client.navigate(&url).await
|
||||||
self.client.click(&tab_id, "gyandoot", r#ref).await
|
.map_err(|e| anyhow::anyhow!("Navigation failed: {}", e))?;
|
||||||
.map_err(|e| anyhow::anyhow!("Click failed: {}", e))?;
|
|
||||||
} else {
|
|
||||||
return Err(anyhow::anyhow!("element_ref is required for click action"));
|
|
||||||
}
|
}
|
||||||
|
"click" => {
|
||||||
|
let r#ref = element_ref.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("element_ref is required for click action"))?;
|
||||||
|
self.client.click(r#ref).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Click failed: {}", e))?;
|
||||||
}
|
}
|
||||||
"type" => {
|
"type" => {
|
||||||
if let Some(ref r#ref) = element_ref {
|
let r#ref = element_ref.as_ref()
|
||||||
if let Some(ref t) = text {
|
.ok_or_else(|| anyhow::anyhow!("element_ref is required for type action"))?;
|
||||||
self.client.type_text(&tab_id, "gyandoot", r#ref, t, press_enter).await
|
let t = text.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("text is required for type action"))?;
|
||||||
|
self.client.type_text(r#ref, t, press_enter).await
|
||||||
.map_err(|e| anyhow::anyhow!("Type failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Type failed: {}", e))?;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return Err(anyhow::anyhow!("element_ref and text are required for type action"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"scroll" => {
|
"scroll" => {
|
||||||
self.client.scroll(&tab_id, "gyandoot", &direction, amount).await
|
self.client.scroll(&direction, amount).await
|
||||||
.map_err(|e| anyhow::anyhow!("Scroll failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Scroll failed: {}", e))?;
|
||||||
}
|
}
|
||||||
"back" => {
|
"back" => {
|
||||||
self.client.go_back(&tab_id, "gyandoot").await
|
self.client.go_back().await
|
||||||
.map_err(|e| anyhow::anyhow!("Go back failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Go back failed: {}", e))?;
|
||||||
}
|
}
|
||||||
"forward" => {
|
"forward" => {
|
||||||
self.client.go_forward(&tab_id, "gyandoot").await
|
self.client.go_forward().await
|
||||||
.map_err(|e| anyhow::anyhow!("Go forward failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Go forward failed: {}", e))?;
|
||||||
}
|
}
|
||||||
"refresh" => {
|
"refresh" => {
|
||||||
self.client.refresh(&tab_id, "gyandoot").await
|
self.client.refresh().await
|
||||||
.map_err(|e| anyhow::anyhow!("Refresh failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Refresh failed: {}", e))?;
|
||||||
}
|
}
|
||||||
_ => {} // already navigated via create_tab
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let snap = self.client.snapshot(&tab_id, "gyandoot").await
|
let snap = self.client.snapshot(false).await
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?;
|
||||||
|
|
||||||
let _ = self.client.close_tab(&tab_id, "gyandoot").await;
|
|
||||||
|
|
||||||
let refs = snap.extract_refs();
|
let refs = snap.extract_refs();
|
||||||
|
let current_url = if snap.url.is_empty() { &url } else { &snap.url };
|
||||||
let response = format!(
|
let response = format!(
|
||||||
"## Page Snapshot\n\n**URL:** {}\n\n**Snapshot:**\n{}\n\n**Element refs:** {:?}\n\n*Use `web_browse` with action=\"click\" and element_ref to interact with elements.*",
|
"## Page Snapshot\n\n**URL:** {}\n\n**Snapshot:**\n{}\n\n**Element refs:** {:?}\n\n*Use `web_browse` with action=\"click\" and element_ref to interact with elements.*",
|
||||||
url,
|
current_url,
|
||||||
snap.snapshot,
|
snap.snapshot,
|
||||||
refs,
|
refs,
|
||||||
);
|
);
|
||||||
@@ -281,4 +300,11 @@ mod tests {
|
|||||||
let params = tool.parameters();
|
let params = tool.parameters();
|
||||||
assert!(params["required"].as_array().unwrap().contains(&"url".to_string().into()));
|
assert!(params["required"].as_array().unwrap().contains(&"url".to_string().into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_site_to_macro() {
|
||||||
|
assert_eq!(site_to_macro("google"), "@google_search");
|
||||||
|
assert_eq!(site_to_macro("youtube"), "@youtube_search");
|
||||||
|
assert_eq!(site_to_macro("reddit"), "@reddit_search");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ name = "gyandoot-browser"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "GyanDoot v2 — Camofox browser automation (anti-detection browsing)"
|
description = "GyanDoot v2 — Camoufox browser automation via daemon IPC (Unix socket protocol)"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gyandoot-core = { path = "../gyandoot-core" }
|
gyandoot-core = { path = "../gyandoot-core" }
|
||||||
@@ -15,4 +15,4 @@ anyhow.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
reqwest = { workspace = true, features = ["json"] }
|
dirs.workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,17 @@
|
|||||||
//! # GyanDoot Browser — Anti-Detection Browsing
|
//! # GyanDoot Browser — Anti-Detection Browsing (Daemon IPC)
|
||||||
//!
|
//!
|
||||||
//! A Rust client for [camofox-browser](https://github.com/jo-inc/camofox-browser),
|
//! A Rust client for [camoufox-browser](https://github.com/msly/camoufox-browser),
|
||||||
//! an anti-detection browser server for AI agents powered by Camoufox.
|
//! communicating via Unix socket JSON-line IPC protocol.
|
||||||
//!
|
//!
|
||||||
//! Camoufox is a Firefox fork with C++-level fingerprint spoofing —
|
//! Camoufox is a Firefox fork with C++-level fingerprint spoofing —
|
||||||
//! no JavaScript shims, no detectable differences from a real browser.
|
//! no JavaScript shims, no detectable differences from a real browser.
|
||||||
//! This crate wraps its REST API with typed Rust interfaces.
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! The `camoufox-browser@0.1.0` npm package runs as a background daemon that
|
||||||
|
//! listens on a Unix socket at `~/.camoufox-browser/<session>.sock`. The daemon
|
||||||
|
//! auto-launches the camoufox browser on-demand. All commands are JSON-line
|
||||||
|
//! messages sent over the socket.
|
||||||
//!
|
//!
|
||||||
//! ## Features
|
//! ## Features
|
||||||
//!
|
//!
|
||||||
@@ -13,8 +19,7 @@
|
|||||||
//! - Token-efficient accessibility snapshots (~90% smaller than HTML)
|
//! - Token-efficient accessibility snapshots (~90% smaller than HTML)
|
||||||
//! - Stable element refs (`e1`, `e2`) for reliable interaction
|
//! - Stable element refs (`e1`, `e2`) for reliable interaction
|
||||||
//! - Session isolation per user
|
//! - Session isolation per user
|
||||||
//! - Proxy + GeoIP support
|
//! - Automatic browser lifecycle (launch on first command)
|
||||||
//! - Automatic idle session cleanup
|
|
||||||
//!
|
//!
|
||||||
//! ## Quick Start
|
//! ## Quick Start
|
||||||
//!
|
//!
|
||||||
@@ -27,13 +32,10 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod macros;
|
|
||||||
pub mod session;
|
|
||||||
pub mod snapshot;
|
|
||||||
|
|
||||||
pub use client::{BrowserClient, BrowserError, BrowserResult, DEFAULT_CAMOFOX_URL};
|
pub use client::{
|
||||||
pub use macros::SearchMacro;
|
BrowserClient, BrowserError, BrowserResult,
|
||||||
pub use session::{BrowserSession, SessionManager, TabInfo};
|
check_daemon_socket, default_socket_path,
|
||||||
pub use snapshot::{
|
HealthResponse, NavigationResult, PageSnapshot, ElementInfo,
|
||||||
HealthResponse, InteractionResult, LinkItem, NavigationResult, PageLinks, PageSnapshot,
|
InteractionResult, TabInfo,
|
||||||
};
|
};
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
//! Search macros for common websites.
|
|
||||||
//!
|
|
||||||
//! Camofox-browser supports 14+ built-in search macros that map to
|
|
||||||
//! site-specific search URLs. Using macros is more reliable than
|
|
||||||
//! manually constructing URLs because the browser handles all
|
|
||||||
//! redirects, cookies, and anti-bot challenges.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
/// All supported search macros.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum SearchMacro {
|
|
||||||
/// `@google_search` — Search Google
|
|
||||||
GoogleSearch,
|
|
||||||
/// `@youtube_search` — Search YouTube
|
|
||||||
YoutubeSearch,
|
|
||||||
/// `@amazon_search` — Search Amazon
|
|
||||||
AmazonSearch,
|
|
||||||
/// `@reddit_search` — Search Reddit (returns JSON)
|
|
||||||
RedditSearch,
|
|
||||||
/// `@reddit_subreddit` — Browse a subreddit (returns JSON)
|
|
||||||
RedditSubreddit,
|
|
||||||
/// `@wikipedia_search` — Search Wikipedia
|
|
||||||
WikipediaSearch,
|
|
||||||
/// `@twitter_search` — Search Twitter/X
|
|
||||||
TwitterSearch,
|
|
||||||
/// `@yelp_search` — Search Yelp
|
|
||||||
YelpSearch,
|
|
||||||
/// `@spotify_search` — Search Spotify
|
|
||||||
SpotifySearch,
|
|
||||||
/// `@netflix_search` — Search Netflix
|
|
||||||
NetflixSearch,
|
|
||||||
/// `@linkedin_search` — Search LinkedIn
|
|
||||||
LinkedinSearch,
|
|
||||||
/// `@instagram_search` — Search Instagram
|
|
||||||
InstagramSearch,
|
|
||||||
/// `@tiktok_search` — Search TikTok
|
|
||||||
TiktokSearch,
|
|
||||||
/// `@twitch_search` — Search Twitch
|
|
||||||
TwitchSearch,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SearchMacro {
|
|
||||||
/// All supported macros as a static slice.
|
|
||||||
pub const ALL: &'static [SearchMacro] = &[
|
|
||||||
SearchMacro::GoogleSearch,
|
|
||||||
SearchMacro::YoutubeSearch,
|
|
||||||
SearchMacro::AmazonSearch,
|
|
||||||
SearchMacro::RedditSearch,
|
|
||||||
SearchMacro::RedditSubreddit,
|
|
||||||
SearchMacro::WikipediaSearch,
|
|
||||||
SearchMacro::TwitterSearch,
|
|
||||||
SearchMacro::YelpSearch,
|
|
||||||
SearchMacro::SpotifySearch,
|
|
||||||
SearchMacro::NetflixSearch,
|
|
||||||
SearchMacro::LinkedinSearch,
|
|
||||||
SearchMacro::InstagramSearch,
|
|
||||||
SearchMacro::TiktokSearch,
|
|
||||||
SearchMacro::TwitchSearch,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The `@name` string sent to the camofox API.
|
|
||||||
pub fn as_macro_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SearchMacro::GoogleSearch => "@google_search",
|
|
||||||
SearchMacro::YoutubeSearch => "@youtube_search",
|
|
||||||
SearchMacro::AmazonSearch => "@amazon_search",
|
|
||||||
SearchMacro::RedditSearch => "@reddit_search",
|
|
||||||
SearchMacro::RedditSubreddit => "@reddit_subreddit",
|
|
||||||
SearchMacro::WikipediaSearch => "@wikipedia_search",
|
|
||||||
SearchMacro::TwitterSearch => "@twitter_search",
|
|
||||||
SearchMacro::YelpSearch => "@yelp_search",
|
|
||||||
SearchMacro::SpotifySearch => "@spotify_search",
|
|
||||||
SearchMacro::NetflixSearch => "@netflix_search",
|
|
||||||
SearchMacro::LinkedinSearch => "@linkedin_search",
|
|
||||||
SearchMacro::InstagramSearch => "@instagram_search",
|
|
||||||
SearchMacro::TiktokSearch => "@tiktok_search",
|
|
||||||
SearchMacro::TwitchSearch => "@twitch_search",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Human-readable site name.
|
|
||||||
pub fn site_name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SearchMacro::GoogleSearch => "Google",
|
|
||||||
SearchMacro::YoutubeSearch => "YouTube",
|
|
||||||
SearchMacro::AmazonSearch => "Amazon",
|
|
||||||
SearchMacro::RedditSearch => "Reddit",
|
|
||||||
SearchMacro::RedditSubreddit => "Reddit (subreddit)",
|
|
||||||
SearchMacro::WikipediaSearch => "Wikipedia",
|
|
||||||
SearchMacro::TwitterSearch => "Twitter/X",
|
|
||||||
SearchMacro::YelpSearch => "Yelp",
|
|
||||||
SearchMacro::SpotifySearch => "Spotify",
|
|
||||||
SearchMacro::NetflixSearch => "Netflix",
|
|
||||||
SearchMacro::LinkedinSearch => "LinkedIn",
|
|
||||||
SearchMacro::InstagramSearch => "Instagram",
|
|
||||||
SearchMacro::TiktokSearch => "TikTok",
|
|
||||||
SearchMacro::TwitchSearch => "Twitch",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a `@name` string into a `SearchMacro`.
|
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
|
||||||
match s {
|
|
||||||
"@google_search" => Some(SearchMacro::GoogleSearch),
|
|
||||||
"@youtube_search" => Some(SearchMacro::YoutubeSearch),
|
|
||||||
"@amazon_search" => Some(SearchMacro::AmazonSearch),
|
|
||||||
"@reddit_search" => Some(SearchMacro::RedditSearch),
|
|
||||||
"@reddit_subreddit" => Some(SearchMacro::RedditSubreddit),
|
|
||||||
"@wikipedia_search" => Some(SearchMacro::WikipediaSearch),
|
|
||||||
"@twitter_search" => Some(SearchMacro::TwitterSearch),
|
|
||||||
"@yelp_search" => Some(SearchMacro::YelpSearch),
|
|
||||||
"@spotify_search" => Some(SearchMacro::SpotifySearch),
|
|
||||||
"@netflix_search" => Some(SearchMacro::NetflixSearch),
|
|
||||||
"@linkedin_search" => Some(SearchMacro::LinkedinSearch),
|
|
||||||
"@instagram_search" => Some(SearchMacro::InstagramSearch),
|
|
||||||
"@tiktok_search" => Some(SearchMacro::TiktokSearch),
|
|
||||||
"@twitch_search" => Some(SearchMacro::TwitchSearch),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for SearchMacro {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.as_macro_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_all_macros_have_valid_names() {
|
|
||||||
for m in SearchMacro::ALL {
|
|
||||||
let name = m.as_macro_str();
|
|
||||||
assert!(name.starts_with('@'), "Macro should start with @");
|
|
||||||
assert!(name.len() > 2, "Macro should have a name");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_roundtrip_parse() {
|
|
||||||
for m in SearchMacro::ALL {
|
|
||||||
let name = m.as_macro_str();
|
|
||||||
let parsed = SearchMacro::from_str(name);
|
|
||||||
assert_eq!(parsed, Some(*m), "Roundtrip failed for {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_macro() {
|
|
||||||
assert_eq!(SearchMacro::from_str("@invalid_search"), None);
|
|
||||||
assert_eq!(SearchMacro::from_str("google_search"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_site_names() {
|
|
||||||
assert_eq!(SearchMacro::GoogleSearch.site_name(), "Google");
|
|
||||||
assert_eq!(SearchMacro::YoutubeSearch.site_name(), "YouTube");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
//! Browser session management — tracks tabs, sessions, and their lifecycle.
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use chrono::Utc;
|
|
||||||
|
|
||||||
/// Represents a browser session (one user's isolated browsing context).
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct BrowserSession {
|
|
||||||
/// Session identifier (maps to camofox `sessionKey`).
|
|
||||||
pub id: String,
|
|
||||||
/// User identifier.
|
|
||||||
pub user_id: String,
|
|
||||||
/// Tabs owned by this session.
|
|
||||||
pub tabs: HashMap<String, TabInfo>,
|
|
||||||
/// When the session was created.
|
|
||||||
pub created_at: chrono::DateTime<Utc>,
|
|
||||||
/// When the session was last active.
|
|
||||||
pub last_active: chrono::DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BrowserSession {
|
|
||||||
pub fn new(user_id: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4().to_string(),
|
|
||||||
user_id: user_id.to_string(),
|
|
||||||
tabs: HashMap::new(),
|
|
||||||
created_at: Utc::now(),
|
|
||||||
last_active: Utc::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn touch(&mut self) {
|
|
||||||
self.last_active = Utc::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_tab(&mut self, tab_id: &str, url: &str) {
|
|
||||||
self.tabs.insert(
|
|
||||||
tab_id.to_string(),
|
|
||||||
TabInfo {
|
|
||||||
id: tab_id.to_string(),
|
|
||||||
url: url.to_string(),
|
|
||||||
created_at: Utc::now(),
|
|
||||||
last_navigated: Utc::now(),
|
|
||||||
page_title: String::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.touch();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_tab(&mut self, tab_id: &str) {
|
|
||||||
self.tabs.remove(tab_id);
|
|
||||||
self.touch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Information about a single browser tab.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TabInfo {
|
|
||||||
/// Camofox tab ID.
|
|
||||||
pub id: String,
|
|
||||||
/// Current URL in the tab.
|
|
||||||
pub url: String,
|
|
||||||
/// When the tab was created.
|
|
||||||
pub created_at: chrono::DateTime<Utc>,
|
|
||||||
/// When the tab last navigated.
|
|
||||||
pub last_navigated: chrono::DateTime<Utc>,
|
|
||||||
/// Current page title.
|
|
||||||
#[serde(default)]
|
|
||||||
pub page_title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TabInfo {
|
|
||||||
pub fn update_title(&mut self, title: &str) {
|
|
||||||
self.page_title = title.to_string();
|
|
||||||
self.last_navigated = Utc::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages all browser sessions.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct SessionManager {
|
|
||||||
sessions: HashMap<String, BrowserSession>,
|
|
||||||
max_sessions: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionManager {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
sessions: HashMap::new(),
|
|
||||||
max_sessions: 50,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_max(mut self, max: usize) -> Self {
|
|
||||||
self.max_sessions = max;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_or_create(&mut self, user_id: &str, session_key: Option<&str>) -> &mut BrowserSession {
|
|
||||||
let key = session_key.unwrap_or(user_id).to_string();
|
|
||||||
if !self.sessions.contains_key(&key) {
|
|
||||||
let session = BrowserSession::new(user_id);
|
|
||||||
self.sessions.insert(key.clone(), session);
|
|
||||||
}
|
|
||||||
self.sessions.get_mut(&key).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, session_key: &str) -> Option<&BrowserSession> {
|
|
||||||
self.sessions.get(session_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_mut(&mut self, session_key: &str) -> Option<&mut BrowserSession> {
|
|
||||||
self.sessions.get_mut(session_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&mut self, session_key: &str) -> Option<BrowserSession> {
|
|
||||||
self.sessions.remove(session_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn all_sessions(&self) -> Vec<&BrowserSession> {
|
|
||||||
self.sessions.values().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn session_count(&self) -> usize {
|
|
||||||
self.sessions.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove sessions that have been idle longer than `timeout` seconds.
|
|
||||||
pub fn cleanup_idle(&mut self, timeout_secs: i64) -> usize {
|
|
||||||
let now = Utc::now();
|
|
||||||
let cutoff = now - chrono::Duration::seconds(timeout_secs);
|
|
||||||
let keys: Vec<String> = self.sessions
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, s)| s.last_active < cutoff)
|
|
||||||
.map(|(k, _)| k.clone())
|
|
||||||
.collect();
|
|
||||||
let count = keys.len();
|
|
||||||
for k in keys {
|
|
||||||
self.sessions.remove(&k);
|
|
||||||
}
|
|
||||||
count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SessionManager {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_session_lifecycle() {
|
|
||||||
let mut mgr = SessionManager::new();
|
|
||||||
let session = mgr.get_or_create("user1", Some("session-1"));
|
|
||||||
session.add_tab("tab-abc", "https://example.com");
|
|
||||||
assert_eq!(session.tabs.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_session_cleanup() {
|
|
||||||
let mut mgr = SessionManager::new();
|
|
||||||
mgr.get_or_create("user1", Some("old-session"));
|
|
||||||
// Sessions are created with Utc::now(), so cleanup_idle with 0 should remove nothing
|
|
||||||
// (they're brand new). Let's use a negative timeout to force cleanup.
|
|
||||||
let cleaned = mgr.cleanup_idle(-1);
|
|
||||||
assert_eq!(cleaned, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_max_sessions() {
|
|
||||||
let mut mgr = SessionManager::new().with_max(3);
|
|
||||||
mgr.get_or_create("u1", Some("s1"));
|
|
||||||
mgr.get_or_create("u2", Some("s2"));
|
|
||||||
mgr.get_or_create("u3", Some("s3"));
|
|
||||||
assert_eq!(mgr.session_count(), 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
//! Types representing accessibility tree snapshots from camofox-browser.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// A page snapshot returned by the camofox-browser API.
|
|
||||||
///
|
|
||||||
/// The snapshot is an accessibility tree with stable element refs
|
|
||||||
/// (e.g., `e1`, `e2`) that can be used for clicking and typing.
|
|
||||||
/// This format is ~90% more token-efficient than raw HTML.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PageSnapshot {
|
|
||||||
/// Accessibility tree text with element refs.
|
|
||||||
pub snapshot: String,
|
|
||||||
/// Optional URL of the page.
|
|
||||||
#[serde(default)]
|
|
||||||
pub url: String,
|
|
||||||
/// Optional page title.
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: String,
|
|
||||||
/// Optional screenshot as base64 PNG.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub screenshot: Option<String>,
|
|
||||||
/// Element reference map: ref -> element info.
|
|
||||||
#[serde(default)]
|
|
||||||
pub elements: HashMap<String, ElementInfo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Information about a single element in the snapshot.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ElementInfo {
|
|
||||||
/// The element's tag name (e.g., "a", "button", "input").
|
|
||||||
#[serde(default)]
|
|
||||||
pub tag: String,
|
|
||||||
/// Visible text content.
|
|
||||||
#[serde(default)]
|
|
||||||
pub text: String,
|
|
||||||
/// URL for links.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub href: Option<String>,
|
|
||||||
/// Whether the element is interactive.
|
|
||||||
#[serde(default)]
|
|
||||||
pub interactive: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PageSnapshot {
|
|
||||||
/// Create a new snapshot from raw text.
|
|
||||||
pub fn new(snapshot: String) -> Self {
|
|
||||||
Self {
|
|
||||||
snapshot,
|
|
||||||
url: String::new(),
|
|
||||||
title: String::new(),
|
|
||||||
screenshot: None,
|
|
||||||
elements: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the snapshot is empty (no content).
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.snapshot.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of characters in the snapshot text.
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.snapshot.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract all element refs from the snapshot text.
|
|
||||||
///
|
|
||||||
/// Refs look like `[e1]`, `[e2]` in the accessibility tree.
|
|
||||||
pub fn extract_refs(&self) -> Vec<String> {
|
|
||||||
let mut refs = Vec::new();
|
|
||||||
for line in self.snapshot.lines() {
|
|
||||||
// Pattern: [eN] or eN
|
|
||||||
if let Some(start) = line.find("[e") {
|
|
||||||
let end = line[start + 2..].find(']').map(|i| start + 2 + i);
|
|
||||||
if let Some(end_pos) = end {
|
|
||||||
let ref_str = &line[start + 1..start + 1 + end_pos + 1];
|
|
||||||
refs.push(ref_str.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refs.sort();
|
|
||||||
refs.dedup();
|
|
||||||
refs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Links extracted from a page.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PageLinks {
|
|
||||||
/// List of links on the page.
|
|
||||||
pub links: Vec<LinkItem>,
|
|
||||||
/// Total count of links.
|
|
||||||
pub total: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single link item.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LinkItem {
|
|
||||||
/// Link text.
|
|
||||||
pub text: String,
|
|
||||||
/// Target URL.
|
|
||||||
pub href: String,
|
|
||||||
/// Optional title attribute.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub title: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Response from page navigation.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct NavigationResult {
|
|
||||||
/// Final URL after navigation.
|
|
||||||
pub url: String,
|
|
||||||
/// Page title.
|
|
||||||
pub title: String,
|
|
||||||
/// Whether navigation succeeded.
|
|
||||||
pub ok: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Response from element interaction (click/type).
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct InteractionResult {
|
|
||||||
/// Whether the interaction succeeded.
|
|
||||||
pub ok: bool,
|
|
||||||
/// New snapshot after interaction (if available).
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub snapshot: Option<PageSnapshot>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Health check response from camofox-browser.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct HealthResponse {
|
|
||||||
/// Server is OK.
|
|
||||||
pub ok: bool,
|
|
||||||
/// Engine type (e.g., "camoufox").
|
|
||||||
pub engine: String,
|
|
||||||
/// Whether browser is connected.
|
|
||||||
#[serde(default)]
|
|
||||||
pub browser_connected: bool,
|
|
||||||
/// Whether browser is running.
|
|
||||||
#[serde(default)]
|
|
||||||
pub browser_running: bool,
|
|
||||||
/// Number of active tabs.
|
|
||||||
#[serde(default)]
|
|
||||||
pub active_tabs: u32,
|
|
||||||
/// Number of active sessions.
|
|
||||||
#[serde(default)]
|
|
||||||
pub active_sessions: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_refs_from_snapshot() {
|
|
||||||
let snap = PageSnapshot::new(
|
|
||||||
"- heading \"Example\" [level=1]\n- link \"Click\" [e1]:\n - /url: https://example.com\n- paragraph: text [e2]\n".into(),
|
|
||||||
);
|
|
||||||
let refs = snap.extract_refs();
|
|
||||||
assert!(refs.contains(&"[e1]".to_string()));
|
|
||||||
assert!(refs.contains(&"[e2]".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_snapshot() {
|
|
||||||
let snap = PageSnapshot::new(String::new());
|
|
||||||
assert!(snap.is_empty());
|
|
||||||
assert_eq!(snap.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_health_response_deserialize() {
|
|
||||||
let json = r#"{"ok":true,"engine":"camoufox","browserConnected":true,"browserRunning":true,"activeTabs":3,"activeSessions":2}"#;
|
|
||||||
let health: HealthResponse = serde_json::from_str(json).unwrap();
|
|
||||||
assert!(health.ok);
|
|
||||||
assert_eq!(health.active_tabs, 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user