1use std::sync::Arc;
12
13use log::{info, warn};
14use tauri::{Emitter, State};
15
16use crate::commands::sessions::SessionPollerWrapper;
17use crate::commands::storage::{CredentialStoreWrapper, DatabaseWrapper};
18use crate::profiles::pin::{self, PinDecision, PinState};
19use crate::profiles::switch::{plan, SwitchStep};
20use crate::profiles::{startup_target, store, Profile, StartupTarget, UnlockOutcome};
21use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
22
23const ASK_ON_START_KEY: &str = "profiles_ask_on_start";
25
26fn service(db: &State<'_, DatabaseWrapper>) -> Result<Arc<RusqliteService>, String> {
27 let database = db.0.lock().map_err(|e| e.to_string())?;
28 Ok(Arc::new(database.service()))
29}
30
31async fn current_server(
43 db: &Arc<RusqliteService>,
44 auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
45) -> Result<(String, String), String> {
46 if let Some(session) = auth_manager.0.get_session().await {
47 return Ok((session.server_id, session.server_url));
48 }
49
50 db.query_optional(
51 Query::new("SELECT id, url FROM servers ORDER BY last_connected_at DESC LIMIT 1"),
52 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
53 )
54 .await
55 .map_err(|e| e.to_string())?
56 .ok_or_else(|| "No server connected".to_string())
57}
58
59async fn ask_on_start(db: &Arc<RusqliteService>) -> bool {
60 let query = Query::with_params(
61 "SELECT value FROM app_settings WHERE key = ?",
62 vec![QueryParam::String(ASK_ON_START_KEY.to_string())],
63 );
64 db.query_optional(query, |row| row.get::<_, String>(0))
65 .await
66 .ok()
67 .flatten()
68 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
69 .unwrap_or(false)
70}
71
72#[tauri::command]
76#[specta::specta]
77pub async fn profiles_list(
78 db: State<'_, DatabaseWrapper>,
79 auth_manager: State<'_, super::auth::AuthManagerWrapper>,
80) -> Result<Vec<Profile>, String> {
81 let svc = service(&db)?;
82 let (server_id, _) = current_server(&svc, &auth_manager).await?;
83 store::list_profiles(&svc, &server_id).await
84}
85
86#[tauri::command]
92#[specta::specta]
93pub async fn profiles_startup_target(
94 db: State<'_, DatabaseWrapper>,
95 auth_manager: State<'_, super::auth::AuthManagerWrapper>,
96) -> Result<StartupTarget, String> {
97 let svc = service(&db)?;
98 let (server_id, _) = match current_server(&svc, &auth_manager).await {
99 Ok(pair) => pair,
100 Err(_) => return Ok(StartupTarget::Picker),
102 };
103 let profiles = store::list_profiles(&svc, &server_id).await?;
104 Ok(startup_target(&profiles, ask_on_start(&svc).await))
105}
106
107#[tauri::command]
116#[specta::specta]
117pub async fn profiles_get_ask_on_start(db: State<'_, DatabaseWrapper>) -> Result<bool, String> {
118 let svc = service(&db)?;
119 Ok(ask_on_start(&svc).await)
120}
121
122#[tauri::command]
126#[specta::specta]
127pub async fn profiles_set_ask_on_start(
128 db: State<'_, DatabaseWrapper>,
129 enabled: bool,
130) -> Result<(), String> {
131 let svc = service(&db)?;
132 let query = Query::with_params(
133 "INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
134 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
135 vec![
136 QueryParam::String(ASK_ON_START_KEY.to_string()),
137 QueryParam::String(if enabled { "1" } else { "0" }.to_string()),
138 ],
139 );
140 svc.execute(query).await.map_err(|e| e.to_string())?;
141 Ok(())
142}
143
144#[tauri::command]
151#[specta::specta]
152#[allow(clippy::too_many_arguments)]
153pub async fn profiles_unlock(
154 app: tauri::AppHandle,
155 db: State<'_, DatabaseWrapper>,
156 creds: State<'_, CredentialStoreWrapper>,
157 auth_manager: State<'_, super::auth::AuthManagerWrapper>,
158 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
159 session_poller: State<'_, SessionPollerWrapper>,
160 user_id: String,
161 pin_code: Option<String>,
162) -> Result<UnlockOutcome, String> {
163 let svc = service(&db)?;
164
165 let profile = store::get_profile(&svc, &user_id)
166 .await?
167 .ok_or_else(|| format!("Unknown profile: {}", user_id))?;
168
169 let (server_id, _) = current_server(&svc, &auth_manager).await?;
172 if profile.server_id != server_id {
173 return Err("Profile belongs to a different server".to_string());
174 }
175
176 if let Some((hash, state)) = store::get_pin(&svc, &user_id).await? {
177 let candidate = pin_code.unwrap_or_default();
178 let matches = pin::verify_pin(&candidate, &hash);
179 let (decision, next_state) = pin::evaluate(&state, chrono::Utc::now(), matches);
180 store::save_pin_state(&svc, &user_id, &next_state).await?;
181
182 match decision {
183 PinDecision::Reject { attempts_remaining } => {
184 return Ok(UnlockOutcome::WrongPin { attempts_remaining })
185 }
186 PinDecision::Locked { until } => {
187 return Ok(UnlockOutcome::LockedOut {
188 until: until.to_rfc3339(),
189 })
190 }
191 PinDecision::Accept => {}
192 }
193 }
194
195 let outgoing = active_user_id(&svc).await;
196 execute_switch(
197 &app,
198 &svc,
199 &repository_manager,
200 &session_poller,
201 outgoing.as_deref(),
202 &user_id,
203 )
204 .await?;
205 adopt_session(db, creds, &auth_manager).await?;
206
207 Ok(UnlockOutcome::Ok { user_id })
208}
209
210#[tauri::command]
221#[specta::specta]
222#[allow(clippy::too_many_arguments)]
223pub async fn profiles_unlock_with_password(
224 app: tauri::AppHandle,
225 db: State<'_, DatabaseWrapper>,
226 creds: State<'_, CredentialStoreWrapper>,
227 auth_manager: State<'_, super::auth::AuthManagerWrapper>,
228 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
229 session_poller: State<'_, SessionPollerWrapper>,
230 user_id: String,
231 password: String,
232 device_id: String,
233) -> Result<UnlockOutcome, String> {
234 let svc = service(&db)?;
235 let profile = store::get_profile(&svc, &user_id)
236 .await?
237 .ok_or_else(|| format!("Unknown profile: {}", user_id))?;
238
239 let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
240 if profile.server_id != server_id {
241 return Err("Profile belongs to a different server".to_string());
242 }
243
244 let result = auth_manager
245 .0
246 .login(&server_url, &profile.username, &password, &device_id)
247 .await?;
248
249 if result.user.id != user_id {
250 return Err("Signed in as a different account".to_string());
251 }
252
253 save_token(&creds, &user_id, &result.access_token)?;
254
255 store::save_pin_state(&svc, &user_id, &PinState::fresh()).await?;
257
258 let outgoing = active_user_id(&svc).await;
259 execute_switch(
260 &app,
261 &svc,
262 &repository_manager,
263 &session_poller,
264 outgoing.as_deref(),
265 &user_id,
266 )
267 .await?;
268 adopt_session(db, creds, &auth_manager).await?;
269
270 Ok(UnlockOutcome::Ok { user_id })
271}
272
273#[tauri::command]
281#[specta::specta]
282pub async fn profiles_add(
283 db: State<'_, DatabaseWrapper>,
284 creds: State<'_, CredentialStoreWrapper>,
285 auth_manager: State<'_, super::auth::AuthManagerWrapper>,
286 username: String,
287 password: String,
288 pin_code: Option<String>,
289 device_id: String,
290) -> Result<Profile, String> {
291 if let Some(code) = &pin_code {
292 pin::validate_pin(code)?;
293 }
294
295 let svc = service(&db)?;
296 let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
297
298 let result = auth_manager
299 .0
300 .login(&server_url, &username, &password, &device_id)
301 .await?;
302
303 let insert = Query::with_params(
304 "INSERT INTO users (id, server_id, username, last_login_at)
305 VALUES (?, ?, ?, CURRENT_TIMESTAMP)
306 ON CONFLICT(id) DO UPDATE SET
307 server_id = excluded.server_id,
308 username = excluded.username,
309 last_login_at = CURRENT_TIMESTAMP",
310 vec![
311 QueryParam::String(result.user.id.clone()),
312 QueryParam::String(server_id.clone()),
313 QueryParam::String(result.user.name.clone()),
314 ],
315 );
316 svc.execute(insert).await.map_err(|e| e.to_string())?;
317
318 save_token(&creds, &result.user.id, &result.access_token)?;
319
320 if let Some(code) = pin_code {
321 let hash = pin::hash_pin(&code)?;
322 store::set_pin(&svc, &result.user.id, &hash).await?;
323 }
324
325 info!("[Profiles] Added profile {}", result.user.name);
326
327 store::get_profile(&svc, &result.user.id)
328 .await?
329 .ok_or_else(|| "Profile vanished after being added".to_string())
330}
331
332#[tauri::command]
340#[specta::specta]
341pub async fn profiles_set_pin(
342 db: State<'_, DatabaseWrapper>,
343 user_id: String,
344 current_pin: Option<String>,
345 new_pin: Option<String>,
346) -> Result<(), String> {
347 let svc = service(&db)?;
348
349 if let Some((hash, _)) = store::get_pin(&svc, &user_id).await? {
350 let provided = current_pin.unwrap_or_default();
351 if !pin::verify_pin(&provided, &hash) {
352 return Err("Current PIN is incorrect".to_string());
353 }
354 }
355
356 match new_pin {
357 Some(code) => {
358 pin::validate_pin(&code)?;
359 let hash = pin::hash_pin(&code)?;
360 store::set_pin(&svc, &user_id, &hash).await
361 }
362 None => store::clear_pin(&svc, &user_id).await,
363 }
364}
365
366#[tauri::command]
374#[specta::specta]
375pub async fn profiles_remove(
376 db: State<'_, DatabaseWrapper>,
377 creds: State<'_, CredentialStoreWrapper>,
378 user_id: String,
379) -> Result<(), String> {
380 let svc = service(&db)?;
381
382 if active_user_id(&svc).await.as_deref() == Some(user_id.as_str()) {
383 return Err("Switch to another profile before removing this one".to_string());
384 }
385
386 {
387 let store = creds.0.lock().map_err(|e| e.to_string())?;
388 if let Err(e) = store.delete_token(&user_id) {
389 warn!("[Profiles] Could not delete stored token: {}", e);
390 }
391 }
392
393 store::remove_profile(&svc, &user_id).await
394}
395
396fn save_token(
399 creds: &State<'_, CredentialStoreWrapper>,
400 user_id: &str,
401 token: &str,
402) -> Result<(), String> {
403 let store = creds.0.lock().map_err(|e| e.to_string())?;
404 store
405 .save_token(user_id, token)
406 .map(|_| ())
407 .map_err(|e| e.to_string())
408}
409
410async fn active_user_id(db: &Arc<RusqliteService>) -> Option<String> {
411 db.query_optional(
412 Query::new("SELECT id FROM users WHERE is_active = 1 LIMIT 1"),
413 |row| row.get::<_, String>(0),
414 )
415 .await
416 .ok()
417 .flatten()
418}
419
420async fn execute_switch(
435 app: &tauri::AppHandle,
436 db: &Arc<RusqliteService>,
437 repository_manager: &State<'_, super::repository::RepositoryManagerWrapper>,
438 session_poller: &State<'_, SessionPollerWrapper>,
439 from: Option<&str>,
440 to: &str,
441) -> Result<(), String> {
442 let online = true;
443 let steps = plan(from, to, online);
444
445 for step in steps {
446 match step {
447 SwitchStep::StopPlayback => {
448 if let Err(e) = app.emit("profile-switch-stop-playback", ()) {
451 warn!("[Profiles] Could not signal playback stop: {}", e);
452 }
453 }
454 SwitchStep::ParkSyncQueue { user_id } => {
455 info!("[Profiles] Parking sync queue for {}", user_id);
459 }
460 SwitchStep::StopSessionPoller => session_poller.0.stop(),
461 SwitchStep::ClearLockscreenMetadata => {
462 if let Err(e) = app.emit("profile-switch-clear-metadata", ()) {
463 warn!("[Profiles] Could not clear lockscreen metadata: {}", e);
464 }
465 }
466 SwitchStep::DestroyRepository => {
467 let manager = &repository_manager.0;
468 for handle in manager.handles() {
469 manager.destroy(&handle);
470 }
471 }
472 SwitchStep::SetActiveUser { user_id } => {
473 set_active_user(db, &user_id).await?;
474 }
475 SwitchStep::BuildRepository { .. } => {
476 }
478 SwitchStep::StartSessionPoller => {
479 }
482 SwitchStep::RefreshVisibility { user_id } => {
483 info!("[Profiles] Visibility refresh queued for {}", user_id);
484 }
485 SwitchStep::EmitSwitched { user_id } => {
486 app.emit("profile-switched", serde_json::json!({ "userId": user_id }))
487 .map_err(|e| e.to_string())?;
488 }
489 }
490 }
491
492 Ok(())
493}
494
495async fn adopt_session(
506 db: State<'_, DatabaseWrapper>,
507 creds: State<'_, CredentialStoreWrapper>,
508 auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
509) -> Result<(), String> {
510 let active = super::storage::storage_get_active_session(db, creds)
511 .await?
512 .ok_or_else(|| "Profile has no stored session".to_string())?;
513
514 let normalized_url = crate::auth::AuthManager::normalize_url(&active.server_url)?;
515
516 auth_manager
517 .0
518 .set_session(Some(crate::auth::Session {
519 user_id: active.user_id,
520 username: active.username,
521 server_id: active.server_id,
522 server_url: normalized_url,
523 server_name: active.server_name,
524 access_token: active.access_token,
525 verified: false,
526 needs_reauth: false,
527 }))
528 .await;
529
530 Ok(())
531}
532
533async fn set_active_user(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
534 db.execute(Query::new("UPDATE users SET is_active = 0"))
535 .await
536 .map_err(|e| e.to_string())?;
537 db.execute(Query::with_params(
538 "UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
539 vec![QueryParam::String(user_id.to_string())],
540 ))
541 .await
542 .map_err(|e| e.to_string())?;
543 Ok(())
544}