Add camofox-browser integration: gyandoot-browser crate + agent tools

New crate: crates/gyandoot-browser/
  - client.rs: Full REST client for camofox-browser API (health, tabs,
    navigation, snapshots, click/type/scroll, search macros)
  - macros.rs: 14 SearchMacro enum (Google, YouTube, Amazon, Reddit,
    Wikipedia, Twitter, Yelp, Spotify, Netflix, LinkedIn, Instagram,
    TikTok, Twitch) with parse/display
  - snapshot.rs: PageSnapshot, PageLinks, NavigationResult, HealthResponse
  - session.rs: BrowserSession, SessionManager with idle cleanup

New module: crates/gyandoot-agent/src/browser_tools.rs
  - WebSearchTool: Search the web using anti-detection browser
  - WebBrowseTool: Browse URLs, click, type, scroll with element refs
  - register_browser_tools(): Wire both tools into ToolExecutor

CLI: Added
                                 o
                                 |
               - - - - - -     .---.     - - - - - -
          - - - - - - - - -  [ ^   ^ ]  - - - - - - - - -
             - - - - - - -   |  ___  |   - - - - - - -
                              `-----'
           ____ __   __  ___   _   _  ____   ___   ___  _____
          / ___|\ \ / / / _ \ | \ | ||  _ \ / _ \ / _ \|_   _|
         | |  _  \ V / / /_\ \|  \| || | | | | | | | | | | |
         | |_| |  | |  |  _  || |\  || |_| | |_| | |_| | | |
          \____/  |_|  |_| |_||_| \_||____/ \___/ \___/  |_|

        o - - - - - - -  knowledge. delivered. - - - - - - - o

Unknown command: browser
Run 'gyandoot help' for usage. subcommand with health/search/browse actions

Installer: Updated install.sh with camofox-browser cloning, Node.js
detection, npm dependency install, and systemd service setup

Camofox-browser running at http://localhost:9377 (Docker, verified working)

Workspace: 12 crates + CLI binary — cargo check passes with 0 errors.
This commit is contained in:
2026-06-11 20:23:35 +05:30
parent 851c838ca5
commit 605747023b
13 changed files with 1578 additions and 5 deletions

View File

@@ -0,0 +1,18 @@
[package]
name = "gyandoot-browser"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "GyanDoot v2 — Camofox browser automation (anti-detection browsing)"
[dependencies]
gyandoot-core = { path = "../gyandoot-core" }
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
thiserror.workspace = true
anyhow.workspace = true
tracing.workspace = true
uuid.workspace = true
chrono.workspace = true
reqwest = { workspace = true, features = ["json"] }

View File

@@ -0,0 +1,502 @@
//! REST client for the camofox-browser API.
//!
//! Provides async methods for all browser operations:
//! tab management, navigation, snapshots, interaction, and search macros.
use std::time::Duration;
use reqwest::Client as HttpClient;
use serde::{Deserialize, Serialize};
use tracing::{info, debug};
use crate::macros::SearchMacro;
use crate::snapshot::{
HealthResponse, InteractionResult, NavigationResult, PageLinks, PageSnapshot,
};
/// Default camofox-browser API endpoint.
pub const DEFAULT_CAMOFOX_URL: &str = "http://localhost:9377";
/// Default HTTP request timeout.
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// Error type for browser operations.
#[derive(Debug, thiserror::Error)]
pub enum BrowserError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("API returned error: {status} - {message}")]
Api { status: u16, message: String },
#[error("Tab not found: {0}")]
TabNotFound(String),
#[error("Session not found: {0}")]
SessionNotFound(String),
#[error("Navigation failed: {0}")]
NavigationFailed(String),
#[error("Snapshot unavailable")]
SnapshotUnavailable,
#[error("Invalid macro: {0}")]
InvalidMacro(String),
}
/// Result type for browser operations.
pub type BrowserResult<T> = Result<T, BrowserError>;
/// Request body for creating a new tab.
#[derive(Debug, Serialize)]
struct CreateTabRequest {
user_id: String,
session_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
}
/// Request body for navigation.
#[derive(Debug, Serialize)]
struct NavigateRequest {
user_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
macro_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
query: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
wait: Option<u64>,
}
/// Request body for element interaction.
#[derive(Debug, Serialize)]
struct InteractionRequest {
user_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
r#ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
selector: 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")]
direction: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
amount: Option<u32>,
}
/// Generic API response wrapper (camofox returns flat JSON).
/// Response from creating a new tab.
#[derive(Debug, Deserialize)]
pub struct TabResponse {
pub tab_id: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub title: String,
}
/// The camofox-browser client.
#[derive(Debug, Clone)]
pub struct BrowserClient {
/// Base URL of the camofox-browser API.
base_url: String,
/// HTTP client with sensible defaults.
http: HttpClient,
/// HTTP request timeout.
timeout: Duration,
}
impl BrowserClient {
/// Create a new client connecting to `localhost:9377`.
pub fn new() -> Self {
Self {
base_url: DEFAULT_CAMOFOX_URL.to_string(),
http: HttpClient::builder()
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.build()
.expect("Failed to create HTTP client"),
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
}
}
/// Create a client with a custom base URL.
pub fn with_base_url(mut self, url: &str) -> Self {
self.base_url = url.trim_end_matches('/').to_string();
self
}
/// Create a client with a custom timeout.
pub fn with_timeout(mut self, secs: u64) -> Self {
self.timeout = Duration::from_secs(secs);
self.http = HttpClient::builder()
.timeout(self.timeout)
.build()
.expect("Failed to create HTTP client");
self
}
// ─── Health ───────────────────────────────────────────────
/// Check if the camofox-browser server is healthy.
pub async fn health(&self) -> BrowserResult<HealthResponse> {
let resp = self.http
.get(format!("{}/health", self.base_url))
.send()
.await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
let health: HealthResponse = resp.json().await?;
Ok(health)
}
// ─── Tab Management ───────────────────────────────────────
/// Create a new browser tab.
pub async fn create_tab(
&self,
user_id: &str,
session_key: &str,
url: Option<&str>,
) -> BrowserResult<TabResponse> {
let body = CreateTabRequest {
user_id: user_id.to_string(),
session_key: session_key.to_string(),
url: url.map(|s| s.to_string()),
};
let resp = self.http
.post(format!("{}/tabs", self.base_url))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
let tab: TabResponse = resp.json().await?;
Ok(tab)
}
/// Close a browser tab.
pub async fn close_tab(&self, tab_id: &str, user_id: &str) -> BrowserResult<()> {
let resp = self.http
.delete(format!("{}/tabs/{}", self.base_url, tab_id))
.query(&[("userId", user_id)])
.send()
.await?;
if !resp.status().is_success() && resp.status().as_u16() != 404 {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
Ok(())
}
// ─── Navigation ───────────────────────────────────────────
/// Navigate to a URL.
pub async fn navigate(
&self,
tab_id: &str,
user_id: &str,
url: &str,
wait_ms: Option<u64>,
) -> BrowserResult<NavigationResult> {
let mut body = NavigateRequest {
user_id: user_id.to_string(),
url: Some(url.to_string()),
macro_name: None,
query: None,
wait: wait_ms,
};
// Support macro syntax directly in the URL field
if let Some(macro_name) = url.strip_prefix('@') {
let full_macro = format!("@{}", macro_name);
body.url = None;
body.macro_name = Some(full_macro);
}
let resp = self.http
.post(format!("{}/tabs/{}/navigate", self.base_url, tab_id))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
let result: NavigationResult = resp.json().await?;
Ok(result)
}
/// Navigate using a search macro.
pub async fn search(
&self,
tab_id: &str,
user_id: &str,
macro_name: &SearchMacro,
query: &str,
wait_ms: Option<u64>,
) -> BrowserResult<NavigationResult> {
let body = NavigateRequest {
user_id: user_id.to_string(),
url: None,
macro_name: Some(macro_name.as_macro_str().to_string()),
query: Some(query.to_string()),
wait: wait_ms,
};
let resp = self.http
.post(format!("{}/tabs/{}/navigate", self.base_url, tab_id))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
let result: NavigationResult = resp.json().await?;
Ok(result)
}
// ─── Snapshots ────────────────────────────────────────────
/// Get an accessibility snapshot of the current page.
pub async fn snapshot(&self, tab_id: &str, user_id: &str) -> BrowserResult<PageSnapshot> {
let resp = self.http
.get(format!("{}/tabs/{}/snapshot", self.base_url, tab_id))
.query(&[("userId", user_id)])
.send()
.await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
let snap: PageSnapshot = resp.json().await?;
Ok(snap)
}
// ─── Element Interaction ──────────────────────────────────
/// Click an element by its ref (e.g., "e1").
pub async fn click(&self, tab_id: &str, user_id: &str, element_ref: &str) -> BrowserResult<InteractionResult> {
let body = InteractionRequest {
user_id: user_id.to_string(),
r#ref: Some(element_ref.to_string()),
selector: None,
text: None,
press_enter: None,
direction: None,
amount: None,
};
let resp = self.http
.post(format!("{}/tabs/{}/click", self.base_url, tab_id))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
let result: InteractionResult = resp.json().await?;
Ok(result)
}
/// Type text into an element and optionally press Enter.
pub async fn type_text(
&self,
tab_id: &str,
user_id: &str,
element_ref: &str,
text: &str,
press_enter: bool,
) -> BrowserResult<InteractionResult> {
let body = InteractionRequest {
user_id: user_id.to_string(),
r#ref: Some(element_ref.to_string()),
selector: None,
text: Some(text.to_string()),
press_enter: Some(press_enter),
direction: None,
amount: None,
};
let resp = self.http
.post(format!("{}/tabs/{}/type", self.base_url, tab_id))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
let result: InteractionResult = resp.json().await?;
Ok(result)
}
// ─── Navigation Controls ──────────────────────────────────
/// Go back in browser history.
pub async fn go_back(&self, tab_id: &str, user_id: &str) -> BrowserResult<NavigationResult> {
let body = serde_json::json!({"userId": user_id});
let resp = self.http
.post(format!("{}/tabs/{}/back", self.base_url, tab_id))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
Ok(resp.json().await?)
}
/// Go forward in browser history.
pub async fn go_forward(&self, tab_id: &str, user_id: &str) -> BrowserResult<NavigationResult> {
let body = serde_json::json!({"userId": user_id});
let resp = self.http
.post(format!("{}/tabs/{}/forward", self.base_url, tab_id))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
Ok(resp.json().await?)
}
/// Refresh the current page.
pub async fn refresh(&self, tab_id: &str, user_id: &str) -> BrowserResult<NavigationResult> {
let body = serde_json::json!({"userId": user_id});
let resp = self.http
.post(format!("{}/tabs/{}/refresh", self.base_url, tab_id))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
Ok(resp.json().await?)
}
/// Scroll the page.
pub async fn scroll(
&self,
tab_id: &str,
user_id: &str,
direction: &str,
amount: u32,
) -> BrowserResult<()> {
let body = InteractionRequest {
user_id: user_id.to_string(),
r#ref: None,
selector: None,
text: None,
press_enter: None,
direction: Some(direction.to_string()),
amount: Some(amount),
};
let resp = self.http
.post(format!("{}/tabs/{}/scroll", self.base_url, tab_id))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
Ok(())
}
/// Get links from the current page.
pub async fn links(&self, tab_id: &str, user_id: &str, limit: Option<u32>) -> BrowserResult<PageLinks> {
let mut req = self.http
.get(format!("{}/tabs/{}/links", self.base_url, tab_id))
.query(&[("userId", user_id)]);
if let Some(l) = limit {
req = req.query(&[("limit", l)]);
}
let resp = req.send().await?;
if !resp.status().is_success() {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
let links: PageLinks = resp.json().await?;
Ok(links)
}
/// Clear all session data for a user.
pub async fn clear_session(&self, user_id: &str) -> BrowserResult<()> {
let resp = self.http
.delete(format!("{}/sessions/{}", self.base_url, user_id))
.send()
.await?;
if !resp.status().is_success() && resp.status().as_u16() != 404 {
return Err(BrowserError::Api {
status: resp.status().as_u16(),
message: resp.text().await.unwrap_or_default(),
});
}
Ok(())
}
}
impl Default for BrowserClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_health_check_live() {
// Only run if camofox is actually running
let client = BrowserClient::new();
match client.health().await {
Ok(health) => {
assert!(health.ok);
assert_eq!(health.engine, "camoufox");
}
Err(_) => {
// Server might not be running — skip test
eprintln!("camofox-browser not running, skipping live test");
}
}
}
#[test]
fn test_macro_detection_in_url() {
let client = BrowserClient::new();
// Just verify the URL prefix detection works logically
assert!("@google_search".starts_with('@'));
assert!(!"https://google.com".starts_with('@'));
}
}

View File

@@ -0,0 +1,39 @@
//! # GyanDoot Browser — Anti-Detection Browsing
//!
//! A Rust client for [camofox-browser](https://github.com/jo-inc/camofox-browser),
//! an anti-detection browser server for AI agents powered by Camoufox.
//!
//! Camoufox is a Firefox fork with C++-level fingerprint spoofing —
//! no JavaScript shims, no detectable differences from a real browser.
//! This crate wraps its REST API with typed Rust interfaces.
//!
//! ## Features
//!
//! - 14 built-in search macros (`@google_search`, `@youtube_search`, etc.)
//! - Token-efficient accessibility snapshots (~90% smaller than HTML)
//! - Stable element refs (`e1`, `e2`) for reliable interaction
//! - Session isolation per user
//! - Proxy + GeoIP support
//! - Automatic idle session cleanup
//!
//! ## Quick Start
//!
//! ```rust,ignore
//! use gyandoot_browser::BrowserClient;
//!
//! let client = BrowserClient::new();
//! let health = client.health().await.unwrap();
//! println!("Browser engine: {}", health.engine);
//! ```
pub mod client;
pub mod macros;
pub mod session;
pub mod snapshot;
pub use client::{BrowserClient, BrowserError, BrowserResult, DEFAULT_CAMOFOX_URL};
pub use macros::SearchMacro;
pub use session::{BrowserSession, SessionManager, TabInfo};
pub use snapshot::{
HealthResponse, InteractionResult, LinkItem, NavigationResult, PageLinks, PageSnapshot,
};

View File

@@ -0,0 +1,165 @@
//! 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");
}
}

View File

@@ -0,0 +1,183 @@
//! 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);
}
}

View File

@@ -0,0 +1,180 @@
//! 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);
}
}