Skip to main content

tail_fin_core/
session.rs

1//! Minimal single-account session coordinator.
2//!
3//! Phase 1 scope: register one [`Site`] + one [`BrowserSession`], provide
4//! `refresh` / `refresh_if_stale` / `validate` / `reload_cookies` entry
5//! points with per-call debouncing.
6//!
7//! Phase 3+ extends this to multi-account pools, credential vaults,
8//! background schedulers, and quarantine / failure-recovery.
9
10use std::sync::Arc;
11use std::time::Instant;
12
13use night_fury_core::BrowserSession;
14use serde_json::Value;
15use tokio::sync::Mutex;
16
17use crate::error::SiteError;
18use crate::site::{SessionStatus, Site};
19
20/// A coordinator pairing one [`Site`] implementation with one [`BrowserSession`].
21///
22/// The manager:
23/// - tracks the last refresh timestamp and respects [`Site::refresh_interval_min`]
24///   in [`Self::refresh_if_stale`]
25/// - exposes the current cookie snapshot for downstream agents
26///
27/// # Concurrency
28///
29/// The internal `Mutex<SessionState>` guards only the cookie snapshot and
30/// `last_refresh` timestamp — it is **not** held across `Site::refresh` /
31/// `Site::validate` calls. Concurrent callers of `refresh` /
32/// `refresh_with_seed` / `validate` will therefore drive the underlying
33/// browser simultaneously, which is generally unsafe (interleaved
34/// navigations, seed cookies overwritten before the refresh navigation
35/// reads them). Callers that share a `SessionManager` across tasks must
36/// externally serialise these calls. A future release may move the lock
37/// to cover the full call; until then, Phase 3+ multi-account pools /
38/// schedulers are expected to own the serialisation boundary.
39///
40/// # Example
41///
42/// ```ignore
43/// use std::sync::Arc;
44/// use tail_fin_common::{BrowserSession, SessionManager};
45/// use tail_fin_twitter::TwitterSite;
46///
47/// let session = BrowserSession::builder().build().await?;
48/// let manager = SessionManager::new(Arc::new(TwitterSite), session);
49///
50/// // Force refresh:
51/// manager.refresh().await?;
52///
53/// // Check validity:
54/// let status = manager.validate().await?;
55/// ```
56pub struct SessionManager {
57    site: Arc<dyn Site>,
58    browser: Arc<BrowserSession>,
59    state: Mutex<SessionState>,
60}
61
62struct SessionState {
63    cookies: Vec<Value>,
64    last_refresh: Option<Instant>,
65}
66
67impl SessionManager {
68    /// Create a manager wrapping a site + browser session. Initial cookie
69    /// snapshot is empty — call `refresh()` or `reload_cookies()` to populate.
70    pub fn new(site: Arc<dyn Site>, browser: BrowserSession) -> Self {
71        Self {
72            site,
73            browser: Arc::new(browser),
74            state: Mutex::new(SessionState {
75                cookies: Vec::new(),
76                last_refresh: None,
77            }),
78        }
79    }
80
81    /// Return the site this manager is scoped to.
82    pub fn site(&self) -> &Arc<dyn Site> {
83        &self.site
84    }
85
86    /// Return the browser session.
87    pub fn browser(&self) -> &Arc<BrowserSession> {
88        &self.browser
89    }
90
91    /// Force a server-side refresh. Updates internal cookie snapshot.
92    ///
93    /// Does NOT respect `refresh_interval_min` — for debounced refresh,
94    /// use `refresh_if_stale`.
95    pub async fn refresh(&self) -> Result<Vec<Value>, SiteError> {
96        let cookies = self.site.refresh(&self.browser).await?;
97        let mut state = self.state.lock().await;
98        state.cookies = cookies.clone();
99        state.last_refresh = Some(Instant::now());
100        Ok(cookies)
101    }
102
103    /// Inject `seed` cookies into the browser, then call [`Site::refresh`].
104    ///
105    /// Use when the caller already has a (possibly stale) cookie set the
106    /// site accepts as "proof of prior session" — the refresh navigation
107    /// uses them to obtain fresh server-issued cookies.
108    ///
109    /// An empty `seed` slice skips the injection and is equivalent to
110    /// calling [`SessionManager::refresh`] directly.
111    ///
112    /// Like `refresh`, this does NOT respect `refresh_interval_min`.
113    pub async fn refresh_with_seed(&self, seed: &[Value]) -> Result<Vec<Value>, SiteError> {
114        if !seed.is_empty() {
115            self.browser.set_cookies(seed.to_vec()).await.map_err(|e| {
116                SiteError::RefreshFailed {
117                    site: self.site.id(),
118                    reason: format!("set_cookies: {e}"),
119                }
120            })?;
121        }
122        self.refresh().await
123    }
124
125    /// Refresh only if the last refresh is older than `site.refresh_interval_min()`.
126    /// Returns `Some(cookies)` on actual refresh, `None` if debounced.
127    pub async fn refresh_if_stale(&self) -> Result<Option<Vec<Value>>, SiteError> {
128        let should_refresh = {
129            let state = self.state.lock().await;
130            match state.last_refresh {
131                None => true,
132                Some(t) => t.elapsed() >= self.site.refresh_interval_min(),
133            }
134        };
135
136        if should_refresh {
137            self.refresh().await.map(Some)
138        } else {
139            Ok(None)
140        }
141    }
142
143    /// Validate session liveness via the site's `validate` hook.
144    pub async fn validate(&self) -> Result<SessionStatus, SiteError> {
145        self.site.validate(&self.browser).await
146    }
147
148    /// Snapshot of the current cookies held by this manager.
149    /// Initially empty until first `refresh()` or `reload_cookies()`.
150    pub async fn cookies(&self) -> Vec<Value> {
151        self.state.lock().await.cookies.clone()
152    }
153
154    /// Reload cookies from the browser without triggering server-side refresh.
155    /// Useful if cookies were updated via some path outside the manager.
156    pub async fn reload_cookies(&self) -> Result<Vec<Value>, SiteError> {
157        let pattern = self
158            .site
159            .cookie_domain_patterns()
160            .first()
161            .copied()
162            .unwrap_or("*");
163        let cookies = self
164            .browser
165            .get_cookies_for_domain(pattern)
166            .await
167            .map_err(|e| SiteError::RefreshFailed {
168                site: self.site.id(),
169                reason: format!("reload_cookies: {e}"),
170            })?;
171        let mut state = self.state.lock().await;
172        state.cookies = cookies.clone();
173        Ok(cookies)
174    }
175}