browsing 0.1.5

Browser automation: navigate, click, extract, screenshot. Standalone browser control via CDP.
Documentation
//! Browser session management using CDP

use crate::browser::cdp::CdpClient;
use crate::browser::navigation::NavigationManager;
use crate::browser::profile::BrowserProfile;
use crate::browser::screenshot::ScreenshotManager;
use crate::browser::session_manager::SessionManager;
use crate::browser::tab_manager::TabManager;
use crate::browser::views::SessionInfo;
use crate::error::{BrowsingError, Result};
use crate::traits::BrowserClient;
use async_trait::async_trait;
use std::sync::Arc;

/// Browser session for managing CDP connections
pub struct Browser {
    session_manager: SessionManager,
    tab_manager: TabManager,
    navigation_manager: NavigationManager,
    screenshot_manager: ScreenshotManager,
    stealth: bool,
}

impl Browser {
    /// Create a new Browser session with given profile
    pub fn new(profile: BrowserProfile) -> Self {
        let stealth = profile.stealth;
        Self {
            session_manager: SessionManager::new(profile),
            tab_manager: TabManager::new(),
            navigation_manager: NavigationManager::new(),
            screenshot_manager: ScreenshotManager::new(),
            stealth,
        }
    }

    /// Set CDP URL to connect to existing browser instead of launching new one
    pub fn with_cdp_url(mut self, cdp_url: String) -> Self {
        self.session_manager = self.session_manager.with_cdp_url(cdp_url);
        self
    }

    /// Start the browser session (launches browser or connects to existing)
    pub async fn start(&mut self) -> Result<()> {
        let _cdp_url = self.session_manager.start().await?;
        let client = self.session_manager.get_cdp_client()?;

        // Enable stealth mode script injection if configured
        if self.stealth {
            crate::browser::stealth::enable_stealth(&client).await?;
        }

        let targets = client
            .send_command("Target.getTargets", serde_json::json!({}))
            .await?;

        if let Some(target_infos) = targets["targetInfos"].as_array() {
            let page_target = target_infos
                .iter()
                .find(|t| t["type"].as_str() == Some("page"))
                .or_else(|| target_infos.first());

            if let Some(target) = page_target
                && let Some(target_id) = target["targetId"].as_str() {
                    let session = self
                        .session_manager
                        .create_session(target_id.to_string(), None)
                        .await?;
                    self.tab_manager
                        .set_current_target_id(target_id.to_string());
                    self.tab_manager
                        .insert_session(target_id.to_string(), session);
                }
        }

        Ok(())
    }

    /// Navigate to the specified URL
    pub async fn navigate(&mut self, url: &str) -> Result<()> {
        let page = self.get_page()?;
        self.navigation_manager.navigate(&page, url).await
    }

    /// Get the current page URL
    pub async fn get_current_url(&self) -> Result<String> {
        let client = self.get_cdp_client()?;
        let session_id = self.get_session_id()?;

        // Use Runtime.evaluate to get the current URL from the page
        let params = serde_json::json!({
            "expression": "window.location.href",
            "returnByValue": true,
        });

        let result = client
            .send_command_with_session("Runtime.evaluate", params, Some(&session_id))
            .await?;

        result
            .get("result")
            .and_then(|v| v.get("value"))
            .and_then(|v| v.as_str())
            .map(|s| s.to_string())
            .ok_or_else(|| BrowsingError::Browser("Failed to get current URL".to_string()))
    }

    /// Stop the browser session and clean up resources
    ///
    /// # Safety Policy
    ///
    /// **IMPORTANT**: This method does NOT delete browser data directories.
    ///
    /// We never cleanup user data for safety reasons:
    /// - Users may specify a custom `user_data_dir` pointing to their real browser profile
    /// - Deleting user data (bookmarks, history, passwords, extensions) would be catastrophic
    /// - Temporary profiles are preserved for debugging and inspection
    ///
    /// Users are responsible for managing their own browser data directories.
    pub async fn stop(&mut self) -> Result<()> {
        // 1. Clear tab manager first (drops session refs to CDP client)
        self.tab_manager = TabManager::new();

        // 2. Stop session manager (handles CDP client cleanup and launcher)
        self.session_manager.stop().await?;

        Ok(())
    }

    /// Get the CDP client for the current session
    pub fn get_cdp_client(&self) -> Result<std::sync::Arc<crate::browser::cdp::CdpClient>> {
        if let Some(target_id) = self.tab_manager.current_target_id()
            && let Some(session) = self.tab_manager.get_session(target_id) {
                return Ok(Arc::clone(&session.client));
            }
        self.session_manager.get_cdp_client()
    }

    /// Get the session ID for the current target
    pub fn get_session_id(&self) -> Result<String> {
        if let Some(target_id) = self.tab_manager.current_target_id()
            && let Some(session) = self.tab_manager.get_session(target_id) {
                return Ok(session.session_id.clone());
            }
        Err(BrowsingError::Browser("No active session".to_string()))
    }

    /// Get a Page actor for the current session
    pub fn get_page(&self) -> Result<crate::actor::Page> {
        let client = self.get_cdp_client()?;
        let session_id = self.get_session_id()?;
        Ok(crate::actor::Page::new(client, session_id))
    }

    /// Get the current target ID
    pub fn get_current_target_id(&self) -> Result<String> {
        self.tab_manager
            .current_target_id()
            .map(|id| id.to_string())
            .ok_or_else(|| BrowsingError::Browser("No current target ID".to_string()))
    }

    /// Take a screenshot of the current page
    pub async fn take_screenshot(
        &self,
        path: Option<&str>,
        full_page: bool,
        format: Option<&str>,
        quality: Option<u32>,
    ) -> Result<Vec<u8>> {
        let page = self.get_page()?;
        self.screenshot_manager
            .take_screenshot(&page, path, full_page, format, quality)
            .await
    }

    /// Get all open tabs
    pub async fn get_tabs(&self) -> Result<Vec<crate::browser::views::TabInfo>> {
        let client = self.get_cdp_client()?;
        self.tab_manager.get_tabs(&client).await
    }

    /// Create a new tab
    pub async fn create_new_tab(&mut self, url: Option<&str>) -> Result<String> {
        let client = self.get_cdp_client()?;
        self.tab_manager.create_tab(&client, url).await
    }

    /// Switch to a different tab by target ID
    pub async fn switch_to_tab(&mut self, target_id: &str) -> Result<()> {
        let client = self.get_cdp_client()?;
        self.tab_manager.switch_to_tab(&client, target_id).await
    }

    /// Close a tab by target ID
    pub async fn close_tab(&mut self, target_id: &str) -> Result<()> {
        let client = self.get_cdp_client()?;
        self.tab_manager.close_tab(&client, target_id).await
    }

    /// Get target ID from short tab ID (last 4 characters)
    pub async fn get_target_id_from_tab_id(&self, tab_id: &str) -> Result<String> {
        let tabs = self.get_tabs().await?;

        // Try to find target ID ending with tab_id
        for tab in tabs {
            if tab.target_id.ends_with(tab_id) {
                return Ok(tab.target_id);
            }
        }

        Err(BrowsingError::Browser(format!(
            "No target ID found ending with {tab_id}"
        )))
    }

    /// Get the current page title
    pub async fn get_current_page_title(&self) -> Result<String> {
        let client = self.get_cdp_client()?;
        let target_id = self.get_current_target_id()?;

        // Get target info
        let params = serde_json::json!({
            "targetId": target_id
        });

        let target_info = client.send_command("Target.getTargetInfo", params).await?;

        let title = target_info
            .get("targetInfo")
            .and_then(|v| v.get("title"))
            .and_then(|v| v.as_str())
            .unwrap_or("Unknown")
            .to_string();

        Ok(title)
    }

    /// Get session information (URL, title, target ID, session ID)
    pub async fn get_session_info(&self) -> Result<SessionInfo> {
        Ok(SessionInfo {
            url: self.get_current_url().await?,
            title: self.get_current_page_title().await?,
            target_id: self.get_current_target_id()?,
            session_id: self.get_session_id()?,
        })
    }

    /// Get browser state summary
    pub async fn get_browser_state_summary(
        &self,
        include_screenshot: bool,
        dom_service: Option<&crate::dom::DomService>,
    ) -> Result<crate::browser::views::BrowserStateSummary> {
        // Get current URL and title
        let url = self
            .get_current_url()
            .await
            .unwrap_or_else(|_| "about:blank".to_string());
        let title = self
            .get_current_page_title()
            .await
            .unwrap_or_else(|_| "Unknown".to_string());

        // Get tabs
        let tabs = self.get_tabs().await.unwrap_or_default();

        // Get DOM state
        let dom_state = if let Some(dom_service) = dom_service {
            let (state, _, _) = dom_service.get_serialized_dom_tree(None).await?;
            state
        } else {
            // Fallback to empty DOM state
            crate::dom::views::SerializedDOMState {
                html: None,
                text: Some("No DOM state available".to_string()),
                markdown: None,
                elements: vec![],
                selector_map: std::collections::HashMap::new(),
                page_intent: None,
                page_intent_confidence: 0.0,
            }
        };

        // Get screenshot if requested
        let screenshot = if include_screenshot {
            match self.take_screenshot(None, false, Some("png"), None).await {
                Ok(data) => {
                    use base64::{Engine as _, engine::general_purpose};
                    Some(general_purpose::STANDARD.encode(&data))
                }
                Err(_) => None,
            }
        } else {
            None
        };

        // Check if PDF viewer
        let is_pdf_viewer = url.ends_with(".pdf") || title.contains("PDF");

        Ok(crate::browser::views::BrowserStateSummary {
            dom_state,
            url,
            title,
            tabs,
            screenshot,
            page_info: None, // Can be populated with Page.getLayoutMetrics if needed
            pixels_above: 0,
            pixels_below: 0,
            browser_errors: vec![],
            is_pdf_viewer,
            recent_events: None,
            pending_network_requests: vec![],
            pagination_buttons: vec![],
            closed_popup_messages: vec![],
        })
    }
}

// BrowserClient trait implementation with proper delegation to managers
#[async_trait]
impl BrowserClient for Browser {
    async fn start(&mut self) -> Result<()> {
        self.start().await
    }

    async fn stop(&mut self) -> Result<()> {
        self.stop().await
    }

    async fn navigate(&mut self, url: &str) -> Result<()> {
        let page = self.get_page()?;
        self.navigation_manager.navigate(&page, url).await
    }

    async fn get_current_url(&self) -> Result<String> {
        self.get_current_url().await
    }

    async fn create_tab(&mut self, url: Option<&str>) -> Result<String> {
        let client = self.get_cdp_client()?;
        self.tab_manager.create_tab(&client, url).await
    }

    async fn switch_to_tab(&mut self, target_id: &str) -> Result<()> {
        let client = self.get_cdp_client()?;
        self.tab_manager.switch_to_tab(&client, target_id).await
    }

    async fn close_tab(&mut self, target_id: &str) -> Result<()> {
        let client = self.get_cdp_client()?;
        self.tab_manager.close_tab(&client, target_id).await
    }

    async fn get_tabs(&self) -> Result<Vec<crate::browser::views::TabInfo>> {
        let client = self.get_cdp_client()?;
        self.tab_manager.get_tabs(&client).await
    }

    async fn get_target_id_from_tab_id(&self, tab_id: &str) -> Result<String> {
        self.get_target_id_from_tab_id(tab_id).await
    }

    fn get_page(&self) -> Result<crate::actor::Page> {
        let client = self.get_cdp_client()?;
        let session_id = self.get_session_id()?;
        Ok(crate::actor::Page::new(client, session_id))
    }

    async fn take_screenshot(&self, path: Option<&str>, full_page: bool) -> Result<Vec<u8>> {
        let page = self.get_page()?;
        self.screenshot_manager
            .take_screenshot(&page, path, full_page, None, None)
            .await
    }

    async fn get_session_info(&self) -> Result<SessionInfo> {
        self.get_session_info().await
    }

    async fn get_current_page_title(&self) -> Result<String> {
        self.get_current_page_title().await
    }

    fn get_cdp_client(&self) -> Result<Arc<CdpClient>> {
        self.get_cdp_client()
    }

    fn get_session_id(&self) -> Result<String> {
        self.get_session_id()
    }

    fn get_current_target_id(&self) -> Result<String> {
        self.get_current_target_id()
    }
}