Skip to main content

squigit/
profile.rs

1// Copyright 2026 a7mddra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Profile authentication shared by every Squigit shell.
5
6use std::collections::HashSet;
7use std::sync::{
8    atomic::{AtomicBool, Ordering},
9    Arc, Mutex, MutexGuard, OnceLock, RwLock,
10};
11use std::time::Instant;
12
13use squigit_auth::auth::{
14    begin_google_auth_flow, complete_google_auth_flow, google_auth_status_page_url_for,
15    hydrate_avatar, AuthFlowSettings, LoopbackAuthPage, LoopbackAuthServer,
16};
17use squigit_auth::CredentialsSource;
18use thiserror::Error;
19
20use crate::storage::{self, Profile, ProfileSnapshot, ProfileStore, StorageError};
21
22macro_rules! profile_log {
23    ($($argument:tt)*) => {
24        if std::env::var_os("SQUIGIT_TUI_ACTIVE").is_none() {
25            eprintln!($($argument)*);
26        }
27    };
28}
29
30pub type Result<T> = std::result::Result<T, ProfileError>;
31
32#[derive(Debug, Error)]
33pub enum ProfileError {
34    #[error("Authentication is already in progress")]
35    AuthenticationInProgress,
36
37    #[error("Authentication cancelled")]
38    AuthenticationCancelled,
39
40    #[error("Authentication timed out")]
41    AuthenticationTimedOut,
42
43    #[error("The active profile cannot be deleted")]
44    ActiveProfileDeletion,
45
46    #[error("Authentication task failed: {0}")]
47    AuthenticationTask(String),
48
49    #[error(transparent)]
50    Auth(#[from] squigit_auth::ProfileError),
51
52    #[error(transparent)]
53    Storage(#[from] StorageError),
54}
55
56impl ProfileError {
57    pub const fn code(&self) -> &'static str {
58        match self {
59            Self::AuthenticationInProgress => "authentication-in-progress",
60            Self::AuthenticationCancelled => "authentication-cancelled",
61            Self::AuthenticationTimedOut => "authentication-timed-out",
62            Self::ActiveProfileDeletion => "active-profile-deletion",
63            Self::AuthenticationTask(_) => "authentication-task-failed",
64            Self::Auth(_) => "authentication-failed",
65            Self::Storage(_) => "profile-storage-failed",
66        }
67    }
68}
69
70struct PendingGoogleAuth {
71    cancelled: Arc<AtomicBool>,
72}
73
74static PENDING_GOOGLE_AUTH: Mutex<Option<PendingGoogleAuth>> = Mutex::new(None);
75static AVATAR_HYDRATIONS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
76
77fn pending_google_auth() -> Result<MutexGuard<'static, Option<PendingGoogleAuth>>> {
78    PENDING_GOOGLE_AUTH
79        .lock()
80        .map_err(|_| ProfileError::AuthenticationTask("authentication state is poisoned".into()))
81}
82
83fn avatar_hydrations() -> &'static Mutex<HashSet<String>> {
84    AVATAR_HYDRATIONS.get_or_init(|| Mutex::new(HashSet::new()))
85}
86
87fn profile_snapshot(store: &ProfileStore) -> Result<ProfileSnapshot> {
88    let snapshot = store.profile_snapshot()?;
89    hydrate_missing_avatars(&snapshot);
90    Ok(snapshot)
91}
92
93/// Read the complete profile state in one operation.
94///
95/// `active_profile == None` is Guest mode. Missing avatars are hydrated in the
96/// background and never delay this response.
97pub fn get_profile_snapshot() -> Result<ProfileSnapshot> {
98    let store = storage::profile_store()?;
99    profile_snapshot(&store)
100}
101
102/// Begin Google OAuth and return the canonical state after a successful login.
103pub async fn start_google_auth() -> Result<ProfileSnapshot> {
104    tokio::task::spawn_blocking(start_google_auth_blocking)
105        .await
106        .map_err(|error| ProfileError::AuthenticationTask(error.to_string()))?
107}
108
109fn start_google_auth_blocking() -> Result<ProfileSnapshot> {
110    let cancelled = Arc::new(AtomicBool::new(false));
111    {
112        let mut pending = pending_google_auth()?;
113        if pending.is_some() {
114            return Err(ProfileError::AuthenticationInProgress);
115        }
116        *pending = Some(PendingGoogleAuth {
117            cancelled: cancelled.clone(),
118        });
119    }
120
121    let result = run_google_auth(cancelled.clone());
122    clear_pending_google_auth(&cancelled);
123    result
124}
125
126fn run_google_auth(cancelled: Arc<AtomicBool>) -> Result<ProfileSnapshot> {
127    let loopback = LoopbackAuthServer::bind()?;
128    let mut settings = google_auth_settings();
129    settings.redirect_uri = loopback.redirect_uri().to_string();
130    let attempt = begin_google_auth_flow(&settings)?;
131    let auth_url = attempt.auth_url().to_string();
132
133    (settings.open_browser)(&auth_url)?;
134
135    let started_at = Instant::now();
136    let callback = loop {
137        if cancelled.load(Ordering::SeqCst) {
138            return Err(ProfileError::AuthenticationCancelled);
139        }
140        if started_at.elapsed() > settings.timeout {
141            return Err(ProfileError::AuthenticationTimedOut);
142        }
143        if let Some(callback) = loopback.recv_timeout()? {
144            break callback;
145        }
146    };
147
148    if cancelled.load(Ordering::SeqCst) {
149        let status_url =
150            google_auth_status_page_url_for(&settings.status_page_url, LoopbackAuthPage::Invalid);
151        let _ = callback.redirect(&status_url);
152        return Err(ProfileError::AuthenticationCancelled);
153    }
154
155    let store = storage::profile_store()?;
156    let result = complete_google_auth_flow(&store, &settings, attempt, callback.callback_url());
157    let page = if result.is_ok() {
158        LoopbackAuthPage::Success
159    } else {
160        LoopbackAuthPage::Invalid
161    };
162    let status_url = google_auth_status_page_url_for(&settings.status_page_url, page);
163    if let Err(error) = callback.redirect(&status_url) {
164        profile_log!("[profile] Failed to redirect the Google auth response: {error}");
165    }
166
167    result?;
168    store.invalidate_last_trusted_reveal()?;
169    profile_snapshot(&store)
170}
171
172static CONFIGURED_GOOGLE_CREDENTIALS: RwLock<Option<CredentialsSource>> = RwLock::new(None);
173
174/// Provide Google OAuth credentials to the profile authentication engine.
175///
176/// This is typically called at application startup by the host environment
177/// (e.g. `squigit-runtime` secrets).
178pub fn set_google_credentials(source: CredentialsSource) {
179    if let Ok(mut lock) = CONFIGURED_GOOGLE_CREDENTIALS.write() {
180        *lock = Some(source);
181    }
182}
183
184/// Convenience method to configure Google OAuth credentials from a raw JSON string.
185pub fn set_google_credentials_json(json: impl Into<String>) {
186    set_google_credentials(CredentialsSource::RawJson(json.into()));
187}
188
189/// Retrieve the currently configured credentials source, if any.
190pub fn configured_google_credentials() -> Option<CredentialsSource> {
191    CONFIGURED_GOOGLE_CREDENTIALS
192        .read()
193        .ok()
194        .and_then(|guard| guard.clone())
195}
196
197/// Check if Google authentication is configured and valid.
198pub fn is_google_auth_configured() -> bool {
199    let settings = google_auth_settings();
200    squigit_auth::auth::validate_google_credentials(&settings).is_ok()
201}
202
203fn google_auth_settings() -> AuthFlowSettings {
204    let mut settings = AuthFlowSettings::new(Arc::new(|url| {
205        #[cfg(target_os = "linux")]
206        {
207            std::process::Command::new("xdg-open")
208                .arg(url)
209                .env_remove("LD_LIBRARY_PATH")
210                .env_remove("ELECTRON_RUN_AS_NODE")
211                .env_remove("GIO_EXTRA_MODULES")
212                .spawn()
213                .map(|_| ())
214                .map_err(|error| {
215                    squigit_auth::ProfileError::Auth(format!(
216                        "Could not open Google authentication: {error}"
217                    ))
218                })
219        }
220        #[cfg(not(target_os = "linux"))]
221        {
222            webbrowser::open(url).map(|_| ()).map_err(|error| {
223                squigit_auth::ProfileError::Auth(format!(
224                    "Could not open Google authentication: {error}"
225                ))
226            })
227        }
228    }));
229    if let Some(source) = configured_google_credentials() {
230        settings.credentials_source = source;
231    }
232    settings
233}
234
235fn clear_pending_google_auth(cancelled: &Arc<AtomicBool>) {
236    let Ok(mut pending) = PENDING_GOOGLE_AUTH.lock() else {
237        return;
238    };
239    if pending
240        .as_ref()
241        .is_some_and(|current| Arc::ptr_eq(&current.cancelled, cancelled))
242    {
243        *pending = None;
244    }
245}
246
247/// Cancel the currently pending Google OAuth flow.
248pub fn cancel_google_auth() -> Result<()> {
249    if let Some(pending) = pending_google_auth()?.take() {
250        pending.cancelled.store(true, Ordering::SeqCst);
251    }
252    Ok(())
253}
254
255/// Delete an inactive profile and return the canonical state.
256pub fn delete_profile(profile_id: &str) -> Result<ProfileSnapshot> {
257    let store = storage::profile_store()?;
258    if store.get_active_profile_id()?.as_deref() == Some(profile_id) {
259        return Err(ProfileError::ActiveProfileDeletion);
260    }
261    store.delete_profile(profile_id)?;
262    store.invalidate_last_trusted_reveal()?;
263    profile_snapshot(&store)
264}
265
266/// Activate a stored profile and return the canonical state.
267pub fn switch_profile(profile_id: &str) -> Result<ProfileSnapshot> {
268    let store = storage::profile_store()?;
269    store.set_active_profile_id(profile_id)?;
270    store.invalidate_last_trusted_reveal()?;
271    profile_snapshot(&store)
272}
273
274/// Enter Guest mode while keeping saved profiles available for later use.
275pub fn logout() -> Result<ProfileSnapshot> {
276    let store = storage::profile_store()?;
277    if store.get_active_profile_id()?.is_some() {
278        store.clear_active_profile_id()?;
279        store.invalidate_last_trusted_reveal()?;
280    }
281    profile_snapshot(&store)
282}
283
284fn hydrate_missing_avatars(snapshot: &ProfileSnapshot) {
285    for profile in &snapshot.profiles {
286        schedule_avatar_hydration(profile);
287    }
288}
289
290fn schedule_avatar_hydration(profile: &Profile) {
291    if profile
292        .avatar_base64
293        .as_deref()
294        .is_some_and(|avatar| !avatar.trim().is_empty())
295    {
296        return;
297    }
298    let Some(url) = profile
299        .avatar_url
300        .as_deref()
301        .filter(|url| !url.trim().is_empty())
302    else {
303        return;
304    };
305
306    let profile_id = profile.id.clone();
307    let url = url.to_string();
308    let hydration_key = format!("{profile_id}\0{url}");
309    {
310        let Ok(mut hydrations) = avatar_hydrations().lock() else {
311            return;
312        };
313        if !hydrations.insert(hydration_key.clone()) {
314            return;
315        }
316    }
317
318    let thread_key = hydration_key.clone();
319    let spawn_result = std::thread::Builder::new()
320        .name(format!("profile-avatar-{}", profile_id))
321        .spawn(move || {
322            match storage::profile_store() {
323                Ok(store) => {
324                    if let Err(error) = hydrate_avatar(&store, &url, Some(&profile_id)) {
325                        profile_log!("[profile] Avatar hydration stopped: {error}");
326                    }
327                }
328                Err(error) => profile_log!("[profile] Could not open avatar storage: {error}"),
329            }
330            if let Ok(mut hydrations) = avatar_hydrations().lock() {
331                hydrations.remove(&thread_key);
332            }
333        });
334
335    if let Err(error) = spawn_result {
336        if let Ok(mut hydrations) = avatar_hydrations().lock() {
337            hydrations.remove(&hydration_key);
338        }
339        profile_log!("[profile] Could not start avatar hydration: {error}");
340    }
341}