Skip to main content

chaser_cf/core/
browser.rs

1//! Browser management for chaser-cf
2
3use crate::error::{ChaserError, ChaserResult};
4use crate::models::ProxyConfig;
5
6use chaser_oxide::cdp::browser_protocol::target::CreateTargetParams;
7use chaser_oxide::{Browser, BrowserConfig, ChaserPage};
8use futures::StreamExt;
9use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10use std::sync::Arc;
11use tokio::sync::Semaphore;
12
13pub struct BrowserManager {
14    browser: Browser,
15    context_semaphore: Arc<Semaphore>,
16    active_contexts: Arc<AtomicUsize>,
17    max_contexts: usize,
18    healthy: Arc<AtomicBool>,
19}
20
21impl BrowserManager {
22    pub async fn new(config: &super::ChaserConfig) -> ChaserResult<Self> {
23        let mut builder = BrowserConfig::builder().viewport(None).args(vec![
24            "--disable-blink-features=AutomationControlled".to_string(),
25            "--disable-infobars".to_string(),
26        ]);
27
28        if let Some(ref path) = config.chrome_path {
29            builder = builder.chrome_executable(path.clone());
30        }
31
32        if !config.headless {
33            builder = builder.with_head();
34        } else {
35            builder = builder.new_headless_mode();
36        }
37
38        let browser_config = builder
39            .build()
40            .map_err(|e| ChaserError::InitFailed(e.to_string()))?;
41
42        let (browser, mut handler) = Browser::launch(browser_config)
43            .await
44            .map_err(|e| ChaserError::InitFailed(e.to_string()))?;
45
46        let healthy = Arc::new(AtomicBool::new(true));
47        let healthy_clone = healthy.clone();
48        tokio::spawn(async move {
49            loop {
50                match handler.next().await {
51                    Some(_) => {}
52                    None => {
53                        healthy_clone.store(false, Ordering::SeqCst);
54                        break;
55                    }
56                }
57            }
58        });
59
60        Ok(Self {
61            browser,
62            context_semaphore: Arc::new(Semaphore::new(config.context_limit)),
63            active_contexts: Arc::new(AtomicUsize::new(0)),
64            max_contexts: config.context_limit,
65            healthy,
66        })
67    }
68
69    pub fn is_healthy(&self) -> bool {
70        self.healthy.load(Ordering::SeqCst)
71    }
72
73    pub fn active_contexts(&self) -> usize {
74        self.active_contexts.load(Ordering::SeqCst)
75    }
76
77    pub fn max_contexts(&self) -> usize {
78        self.max_contexts
79    }
80
81    pub async fn acquire_permit(&self) -> ChaserResult<ContextPermit> {
82        let permit = self
83            .context_semaphore
84            .clone()
85            .acquire_owned()
86            .await
87            .map_err(|_| ChaserError::ContextFailed("Semaphore closed".to_string()))?;
88
89        self.active_contexts.fetch_add(1, Ordering::SeqCst);
90
91        Ok(ContextPermit {
92            _permit: permit,
93            active_contexts: self.active_contexts.clone(),
94        })
95    }
96
97    pub fn try_acquire_permit(&self) -> Option<ContextPermit> {
98        let permit = self.context_semaphore.clone().try_acquire_owned().ok()?;
99        self.active_contexts.fetch_add(1, Ordering::SeqCst);
100        Some(ContextPermit {
101            _permit: permit,
102            active_contexts: self.active_contexts.clone(),
103        })
104    }
105
106    pub async fn create_context(
107        &self,
108        proxy: Option<&ProxyConfig>,
109    ) -> ChaserResult<Option<chaser_oxide::cdp::browser_protocol::browser::BrowserContextId>> {
110        match proxy {
111            Some(p) => {
112                let ctx_id = self
113                    .browser
114                    .create_incognito_context_with_proxy(p.to_url())
115                    .await
116                    .map_err(|e| ChaserError::ContextFailed(e.to_string()))?;
117                Ok(Some(ctx_id))
118            }
119            None => Ok(None),
120        }
121    }
122
123    /// Open a blank page, apply the native profile (OS + real Chrome version), then
124    /// navigate to `url`. Proxy auth is handled by the caller before navigation.
125    pub async fn new_page(
126        &self,
127        ctx_id: Option<chaser_oxide::cdp::browser_protocol::browser::BrowserContextId>,
128        url: &str,
129    ) -> ChaserResult<(chaser_oxide::Page, ChaserPage)> {
130        let mut params = CreateTargetParams::new("about:blank");
131        if let Some(id) = ctx_id {
132            params.browser_context_id = Some(id);
133        }
134
135        let page = self
136            .browser
137            .new_page(params)
138            .await
139            .map_err(|e| ChaserError::PageFailed(e.to_string()))?;
140
141        let chaser = ChaserPage::new(page.clone());
142
143        // apply_native_profile reads the real Chrome version from the live browser UA
144        // and pairs it with the host OS + RAM, then issues Emulation.setUserAgentOverride
145        // with full userAgentMetadata so Sec-CH-UA-Platform/version are self-consistent.
146        chaser
147            .apply_native_profile()
148            .await
149            .map_err(|e| ChaserError::PageFailed(format!("apply_native_profile: {e}")))?;
150
151        if url != "about:blank" {
152            chaser
153                .goto(url)
154                .await
155                .map_err(|e| ChaserError::NavigationFailed(e.to_string()))?;
156        }
157
158        Ok((page, chaser))
159    }
160
161    pub async fn shutdown(self) {
162        self.healthy.store(false, Ordering::SeqCst);
163    }
164}
165
166pub struct ContextPermit {
167    _permit: tokio::sync::OwnedSemaphorePermit,
168    active_contexts: Arc<AtomicUsize>,
169}
170
171impl Drop for ContextPermit {
172    fn drop(&mut self) {
173        self.active_contexts.fetch_sub(1, Ordering::SeqCst);
174    }
175}