Skip to main content

#
jellytau_lib/commands/
profiles.rs

    1//! Profile commands: who can use this device, and how they get in.
    2//!
    3//! The rule that shapes this whole module: **switching is not logging out**.
    4//! [`auth_logout`](super::auth::auth_logout) calls Jellyfin's logout endpoint,
    5//! which invalidates the token server-side — so a switch built on it would make
    6//! every switch back cost a password, which is the problem this feature exists
    7//! to solve. Nothing here calls it.
    8//!
    9//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268, DR-269, DR-270, DR-274
    10
    11use 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
    23/// Settings key for "ask who's watching on start".
    24const 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
    31/// The server this device is signed in to, as `(server_id, server_url)`.
    32///
    33/// Every profile operation is scoped to it — this is where the same-server
    34/// constraint is actually enforced, rather than by omitting a URL field from a
    35/// form.
    36///
    37/// The fallback to the `servers` table is not a convenience. When the last-used
    38/// profile has a PIN, `auth_initialize` deliberately does **not** restore its
    39/// session, so at startup there is no in-memory session to read — and the picker
    40/// still has to know which server's profiles to list. Reading it from storage is
    41/// what lets the PIN gate a real thing rather than just a screen.
    42async 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/// List the accounts this device knows for the current server.
    73///
    74/// TRACES: UR-082 | DR-267
    75#[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/// Whether startup should resume an account or ask who is watching.
    87///
    88/// The decision is backend state, so the frontend asks rather than computes it.
    89///
    90/// TRACES: UR-082 | DR-274
    91#[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        // Nothing signed in yet: the picker doubles as first-run login.
    101        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/// Read the "ask who's watching on start" setting.
    108///
    109/// Separate from [`profiles_startup_target`] on purpose: the target can be
    110/// `Picker` for reasons that have nothing to do with this setting — a
    111/// PIN-protected last profile always asks — so deriving the toggle's position
    112/// from it would show the user a switch that does not describe what it controls.
    113///
    114/// TRACES: UR-082 | DR-274
    115#[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/// Turn "ask who's watching on start" on or off.
    123///
    124/// TRACES: UR-082 | DR-274
    125#[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/// Enter a profile, with its PIN if it has one.
    145///
    146/// A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
    147/// invent a lock the backend does not have, and cannot skip one it does.
    148///
    149/// TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
    150#[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    // Same-server constraint, checked at the point of use rather than trusted
    170    // from the caller.
    171    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/// Enter a profile with its Jellyfin password, for someone who has forgotten
    211/// their PIN.
    212///
    213/// There is deliberately no reset token and no recovery secret: the account's
    214/// own password is already the authority over it, and a second credential
    215/// guarding the same thing would only be a weaker one. A successful password
    216/// entry also clears the lockout, which is what makes a forgotten PIN a
    217/// detour rather than a dead end.
    218///
    219/// TRACES: UR-084 | DR-269
    220#[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    // The password got in, so the PIN counters have served their purpose.
    256    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/// Add another account from the **current** server to this device.
    274///
    275/// Takes no server URL. That is the same-server constraint expressed as a
    276/// signature rather than as form validation: there is no way to ask this command
    277/// for an account somewhere else.
    278///
    279/// TRACES: UR-082 | DR-267
    280#[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/// Set, change, or clear a profile's PIN.
    333///
    334/// Changing an existing PIN requires the current one. Clearing it (`new_pin =
    335/// None`) does too — otherwise the lock could be removed by whoever is standing
    336/// in front of the unlocked device, which is exactly who it exists to stop.
    337///
    338/// TRACES: UR-083 | DR-268
    339#[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/// Forget a profile on this device.
    367///
    368/// Does not call Jellyfin's logout endpoint: removing an account from the family
    369/// TV should not sign that person out on their phone. The stored token is
    370/// deleted locally, which is the part that actually belongs to this device.
    371///
    372/// TRACES: UR-082 | DR-267
    373#[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
    396// --- internals ---------------------------------------------------------------
    397
    398fn 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
    420/// Run a switch plan.
    421///
    422/// The ordering comes from [`crate::profiles::switch::plan`] rather than being
    423/// written out here, because the ordering is the invariant worth testing and an
    424/// end-to-end switch needs two real accounts on a real server to exercise.
    425///
    426/// One step is deliberately not executed here: `BuildRepository`. Repository
    427/// handles are created by the frontend (`repository_create`) because building
    428/// one needs the token and URL it already assembles at login, so the
    429/// `profile-switched` event is the signal to do it. What stays in Rust is the
    430/// part that matters — that the old handle is destroyed *before* the active user
    431/// flips, so nothing can write under the wrong id in between.
    432///
    433/// TRACES: UR-082 | DR-270
    434async 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                // The queue cannot outlive its owner: a report landing after the
    449                // flip would attribute one account's viewing to another.
    450                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                // Rows stay queued under their own user id; nothing is dropped.
    456                // Parking is simply declining to drain them under a different
    457                // token, which the drain already keys on.
    458                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                // Owned by the frontend; see the doc comment above.
    477            }
    478            SwitchStep::StartSessionPoller => {
    479                // The poller restarts with the new session once the frontend has
    480                // built its repository, for the same reason.
    481            }
    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
    495/// Make the newly-active profile the session the rest of the backend acts as.
    496///
    497/// The token lives in the credential store, which the frontend cannot read, so
    498/// the swap has to happen here — the frontend then rebuilds its repository
    499/// handle from the session it can now read back. This mirrors what
    500/// [`auth_initialize`](super::auth::auth_initialize) does on a cold start, and
    501/// deliberately reuses the same storage path rather than a second one that could
    502/// drift from it.
    503///
    504/// TRACES: UR-082 | DR-270
    505async 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}