1use 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
93pub fn get_profile_snapshot() -> Result<ProfileSnapshot> {
98 let store = storage::profile_store()?;
99 profile_snapshot(&store)
100}
101
102pub 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
174pub fn set_google_credentials(source: CredentialsSource) {
179 if let Ok(mut lock) = CONFIGURED_GOOGLE_CREDENTIALS.write() {
180 *lock = Some(source);
181 }
182}
183
184pub fn set_google_credentials_json(json: impl Into<String>) {
186 set_google_credentials(CredentialsSource::RawJson(json.into()));
187}
188
189pub fn configured_google_credentials() -> Option<CredentialsSource> {
191 CONFIGURED_GOOGLE_CREDENTIALS
192 .read()
193 .ok()
194 .and_then(|guard| guard.clone())
195}
196
197pub 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(¤t.cancelled, cancelled))
242 {
243 *pending = None;
244 }
245}
246
247pub 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
255pub 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
266pub 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
274pub 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}