Skip to main content

night_fury_core/domains/
context.rs

1use tokio::sync::oneshot;
2
3use crate::error::NightFuryError;
4use crate::session::BrowserSession;
5use crate::worker::WorkerState;
6
7// ---------------------------------------------------------------------------
8// Command enum
9// ---------------------------------------------------------------------------
10
11/// Commands for the browser context domain (isolated sessions).
12#[non_exhaustive]
13pub enum ContextCmd {
14    /// Create a new isolated browser context (separate cookies, localStorage, cache).
15    NewContext {
16        reply: oneshot::Sender<Result<String, String>>,
17    },
18    /// Open a new tab inside an existing browser context.
19    NewContextTab {
20        context_id: String,
21        url: Option<String>,
22        reply: oneshot::Sender<Result<usize, String>>,
23    },
24    /// Dispose of a browser context and close all its tabs.
25    CloseContext {
26        context_id: String,
27        reply: oneshot::Sender<Result<String, String>>,
28    },
29}
30
31// ---------------------------------------------------------------------------
32// Dispatch
33// ---------------------------------------------------------------------------
34
35impl ContextCmd {
36    pub(crate) async fn dispatch(self, state: &mut WorkerState) {
37        match self {
38            ContextCmd::NewContext { reply } => handle_new_context(state, reply).await,
39            ContextCmd::NewContextTab {
40                context_id,
41                url,
42                reply,
43            } => handle_new_context_tab(state, context_id, url, reply).await,
44            ContextCmd::CloseContext { context_id, reply } => {
45                handle_close_context(state, context_id, reply).await
46            }
47        }
48    }
49}
50
51// ---------------------------------------------------------------------------
52// Handlers
53// ---------------------------------------------------------------------------
54
55async fn handle_new_context(
56    state: &mut WorkerState,
57    reply: oneshot::Sender<Result<String, String>>,
58) {
59    let result: Result<String, String> = async {
60        let ctx_id = state
61            .browser
62            .create_browser_context(
63                chromiumoxide_cdp::cdp::browser_protocol::target::CreateBrowserContextParams::default(),
64            )
65            .await
66            .map_err(|e| format!("CreateBrowserContext failed: {e}"))?;
67        Ok(ctx_id.inner().to_string())
68    }
69    .await;
70    let _ = reply.send(result);
71}
72
73async fn handle_new_context_tab(
74    state: &mut WorkerState,
75    context_id: String,
76    url: Option<String>,
77    reply: oneshot::Sender<Result<usize, String>>,
78) {
79    let result: Result<usize, String> = async {
80        use chaser_oxide::ChaserPage;
81        use chromiumoxide_cdp::cdp::browser_protocol::browser::BrowserContextId;
82        use chromiumoxide_cdp::cdp::browser_protocol::target::CreateTargetParams;
83
84        let target_url = url.as_deref().unwrap_or("about:blank");
85        let ctx_id_str = context_id.clone();
86        let mut params = CreateTargetParams::new(target_url);
87        params.browser_context_id = Some(BrowserContextId::from(context_id));
88
89        let page = state
90            .browser
91            .new_page(params)
92            .await
93            .map_err(|e| format!("new_page in context failed: {e}"))?;
94        let chaser = ChaserPage::new(page);
95
96        if let Some(ref profile) = state.stealth_profile {
97            chaser
98                .apply_profile(profile)
99                .await
100                .map_err(|e| format!("apply_profile failed: {e}"))?;
101        }
102
103        let idx = state.tabs.len();
104        state.tabs.push(crate::worker::TabState {
105            page: chaser,
106            snapshot_refs: Vec::new(),
107            active_frame: None,
108            browser_context_id: Some(ctx_id_str),
109        });
110        state.active_tab = idx;
111
112        crate::domains::tabs::spawn_page_listeners(
113            &state.tabs[idx].page,
114            std::sync::Arc::clone(&state.dialog_config),
115            std::sync::Arc::clone(&state.last_dialog),
116            std::sync::Arc::clone(&state.console_enabled),
117            std::sync::Arc::clone(&state.console_log),
118        )
119        .await;
120
121        Ok(idx)
122    }
123    .await;
124    let _ = reply.send(result);
125}
126
127async fn handle_close_context(
128    state: &mut WorkerState,
129    context_id: String,
130    reply: oneshot::Sender<Result<String, String>>,
131) {
132    let result: Result<String, String> = async {
133        use chromiumoxide_cdp::cdp::browser_protocol::browser::BrowserContextId;
134
135        state
136            .browser
137            .dispose_browser_context(BrowserContextId::from(context_id.clone()))
138            .await
139            .map_err(|e| format!("DisposeBrowserContext failed: {e}"))?;
140
141        // Remove all tabs belonging to this context from worker state.
142        // Iterate in reverse to preserve indices while removing.
143        let mut i = state.tabs.len();
144        while i > 0 {
145            i -= 1;
146            if state.tabs[i].browser_context_id.as_deref() == Some(&context_id) {
147                state.tabs.remove(i);
148                // Adjust active_tab if it pointed at or after the removed index.
149                if state.active_tab >= state.tabs.len() && !state.tabs.is_empty() {
150                    state.active_tab = state.tabs.len() - 1;
151                }
152            }
153        }
154
155        Ok(format!("Disposed browser context {context_id}"))
156    }
157    .await;
158    let _ = reply.send(result);
159}
160
161// ---------------------------------------------------------------------------
162// BrowserContext — a handle for an isolated browser context
163// ---------------------------------------------------------------------------
164
165/// A handle to an isolated browser context with its own cookies, localStorage,
166/// and cache. Created via [`BrowserSession::new_context`].
167///
168/// `BrowserContext` is `Clone + Send + 'static` — it wraps a
169/// `BrowserSession` sender clone and the CDP `browserContextId`.
170#[derive(Clone, Debug)]
171pub struct BrowserContext {
172    session: BrowserSession,
173    context_id: String,
174}
175
176impl BrowserContext {
177    pub(crate) fn new(session: BrowserSession, context_id: String) -> Self {
178        Self {
179            session,
180            context_id,
181        }
182    }
183
184    /// The CDP browser context ID.
185    pub fn id(&self) -> &str {
186        &self.context_id
187    }
188
189    /// Open a new tab inside this context. If `url` is provided the tab
190    /// navigates to it immediately. Returns the zero-based tab index.
191    pub async fn new_tab(&self, url: Option<&str>) -> Result<usize, NightFuryError> {
192        send_cmd!(
193            self.session,
194            |tx| crate::cmd::BrowserCmd::Context(ContextCmd::NewContextTab {
195                context_id: self.context_id.clone(),
196                url: url.map(String::from),
197                reply: tx,
198            }),
199            NightFuryError::OperationFailed
200        )
201    }
202
203    /// Dispose of this browser context and close all tabs that belong to it.
204    pub async fn close(self) -> Result<String, NightFuryError> {
205        send_cmd!(
206            self.session,
207            |tx| crate::cmd::BrowserCmd::Context(ContextCmd::CloseContext {
208                context_id: self.context_id.clone(),
209                reply: tx,
210            }),
211            NightFuryError::OperationFailed
212        )
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Session API
218// ---------------------------------------------------------------------------
219
220impl BrowserSession {
221    /// Create a new isolated browser context with its own cookies, localStorage,
222    /// and cache. Returns a [`BrowserContext`] handle that can open tabs and be
223    /// disposed when no longer needed.
224    pub async fn new_context(&self) -> Result<BrowserContext, NightFuryError> {
225        let context_id: String = send_cmd!(
226            self,
227            |tx| crate::cmd::BrowserCmd::Context(ContextCmd::NewContext { reply: tx }),
228            NightFuryError::OperationFailed
229        )?;
230        Ok(BrowserContext::new(self.clone(), context_id))
231    }
232}
233
234// ---------------------------------------------------------------------------
235// Tests
236// ---------------------------------------------------------------------------
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use tokio::sync::mpsc;
242
243    #[test]
244    fn browser_context_clone_debug() {
245        let (tx, _rx) = mpsc::channel::<crate::cmd::BrowserCmd>(1);
246        let session = BrowserSession::from_sender(tx);
247        let ctx = BrowserContext::new(session, "test-ctx-id".to_string());
248        let cloned = ctx.clone();
249        assert_eq!(cloned.id(), "test-ctx-id");
250        // Debug derive works
251        let dbg = format!("{:?}", cloned);
252        assert!(dbg.contains("test-ctx-id"));
253    }
254
255    #[test]
256    fn browser_context_id() {
257        let (tx, _rx) = mpsc::channel::<crate::cmd::BrowserCmd>(1);
258        let session = BrowserSession::from_sender(tx);
259        let ctx = BrowserContext::new(session, "abc-123".to_string());
260        assert_eq!(ctx.id(), "abc-123");
261    }
262
263    #[tokio::test]
264    async fn new_context_worker_dead() {
265        let (tx, rx) = mpsc::channel::<crate::cmd::BrowserCmd>(1);
266        drop(rx);
267        let session = BrowserSession::from_sender(tx);
268        let err = session.new_context().await.unwrap_err();
269        assert!(
270            matches!(err, NightFuryError::WorkerDead),
271            "expected WorkerDead, got {err:?}"
272        );
273    }
274
275    #[tokio::test]
276    async fn new_context_tab_worker_dead() {
277        let (tx, rx) = mpsc::channel::<crate::cmd::BrowserCmd>(1);
278        drop(rx);
279        let session = BrowserSession::from_sender(tx);
280        let ctx = BrowserContext::new(session, "dead-ctx".to_string());
281        let err = ctx.new_tab(Some("https://example.com")).await.unwrap_err();
282        assert!(
283            matches!(err, NightFuryError::WorkerDead),
284            "expected WorkerDead, got {err:?}"
285        );
286    }
287
288    #[tokio::test]
289    async fn close_context_worker_dead() {
290        let (tx, rx) = mpsc::channel::<crate::cmd::BrowserCmd>(1);
291        drop(rx);
292        let session = BrowserSession::from_sender(tx);
293        let ctx = BrowserContext::new(session, "dead-ctx".to_string());
294        let err = ctx.close().await.unwrap_err();
295        assert!(
296            matches!(err, NightFuryError::WorkerDead),
297            "expected WorkerDead, got {err:?}"
298        );
299    }
300}