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}