Skip to main content

#
jellytau_lib/profiles/
mod.rs

    1//! Multi-user profiles on one device.
    2//!
    3//! A "profile" is an account on the *currently connected* Jellyfin server that
    4//! this device has signed into at least once. The rows have existed since the
    5//! first schema (`users`), and so have `storage_get_users` /
    6//! `storage_set_active_user`; what was missing was never the storage but the
    7//! decision of who may switch to what — which is domain logic, and stays here.
    8//!
    9//! Two things this module is careful about:
    10//!
    11//! - **Switching is not logging out.** `auth_logout` calls Jellyfin's logout
    12//!   endpoint, which invalidates the token server-side. That is precisely the
    13//!   behaviour a switch must not have, or every switch back would need a
    14//!   password. Nothing here calls it.
    15//! - **"Child account" is not modelled.** A child's profile is simply one with
    16//!   no PIN. The frontend receives an opaque [`UnlockMethod`] and renders it; it
    17//!   never infers a role, and no role taxonomy is invented on either side.
    18//!
    19//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268
    20
    21pub mod pin;
    22pub mod store;
    23pub mod switch;
    24
    25use serde::{Deserialize, Serialize};
    26
    27/// How a profile is entered.
    28///
    29/// Deliberately not "adult"/"child": the app has no way to know a person's age
    30/// and no business encoding one. It knows whether a code is set.
    31///
    32/// TRACES: UR-083 | DR-276
    33#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    34#[serde(rename_all = "camelCase")]
    35pub enum UnlockMethod {
    36    /// One tap. No code set.
    37    None,
    38    /// A numeric code gates the switch.
    39    Pin,
    40}
    41
    42/// A switchable account on this device.
    43///
    44/// TRACES: UR-082 | DR-267
    45#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
    46#[serde(rename_all = "camelCase")]
    47pub struct Profile {
    48    pub user_id: String,
    49    pub username: String,
    50    pub server_id: String,
    51    /// Jellyfin's primary-image tag, for the tile. `None` renders initials.
    52    pub avatar_tag: Option<String>,
    53    pub unlock_method: UnlockMethod,
    54    pub last_used_at: Option<String>,
    55    pub is_active: bool,
    56}
    57
    58/// The result of an unlock attempt.
    59///
    60/// Note the explicit field renames. tauri-specta emits tagged-union *fields*
    61/// with their Rust names rather than camelCasing them, so a field that would
    62/// differ between the two conventions is renamed here by hand — the same trap
    63/// that produced `new_url` on the frontend once already.
    64///
    65/// TRACES: UR-083, UR-084 | DR-268, DR-269
    66#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    67#[serde(tag = "type", rename_all = "camelCase")]
    68pub enum UnlockOutcome {
    69    /// Switched. The profile is now active.
    70    Ok {
    71        #[serde(rename = "userId")]
    72        user_id: String,
    73    },
    74    /// Wrong code, attempts left.
    75    WrongPin {
    76        #[serde(rename = "attemptsRemaining")]
    77        attempts_remaining: u32,
    78    },
    79    /// Too many wrong codes; refused until this RFC3339 instant.
    80    LockedOut { until: String },
    81    /// No code is recoverable from here — sign in with the account password.
    82    NeedsPassword,
    83}
    84
    85/// What the app should do when it starts.
    86///
    87/// The decision is backend state (profile count, PIN presence, a stored
    88/// setting), so the frontend asks rather than computes. A single account with no
    89/// PIN always resumes, which is what keeps this feature invisible until it is
    90/// wanted.
    91///
    92/// TRACES: UR-082 | DR-274
    93#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    94#[serde(tag = "type", rename_all = "camelCase")]
    95pub enum StartupTarget {
    96    /// Resume this profile without asking.
    97    Resume {
    98        #[serde(rename = "userId")]
    99        user_id: String,
    100    },
    101    /// Show the picker.
    102    Picker,
    103}
    104
    105/// Decide the startup target from the profiles present and the user's setting.
    106///
    107/// Pure, because the rule is worth testing and the inputs are trivial to state:
    108///
    109/// - No profiles at all → picker (which renders as the first-run login).
    110/// - The last-used profile has a PIN → picker, regardless of the setting. A code
    111///   that could be skipped by relaunching is not a code.
    112/// - More than one profile and "ask who's watching" is on → picker.
    113/// - Otherwise → resume, exactly as the app behaved before profiles existed.
    114///
    115/// TRACES: UR-082 | DR-274
    116pub fn startup_target(profiles: &[Profile], ask_on_start: bool) -> StartupTarget {
    117    let last_used = profiles
    118        .iter()
    119        .max_by(|a, b| a.last_used_at.cmp(&b.last_used_at));
    120
    121    match last_used {
    122        None => StartupTarget::Picker,
    123        Some(p) if p.unlock_method == UnlockMethod::Pin => StartupTarget::Picker,
    124        Some(_) if ask_on_start && profiles.len() > 1 => StartupTarget::Picker,
    125        Some(p) => StartupTarget::Resume {
    126            user_id: p.user_id.clone(),
    127        },
    128    }
    129}
    130
    131#[cfg(test)]
    132mod tests {
    133    use super::*;
    134
    135    fn profile(id: &str, unlock: UnlockMethod, last_used: Option<&str>) -> Profile {
    136        Profile {
    137            user_id: id.to_string(),
    138            username: id.to_string(),
    139            server_id: "server-1".to_string(),
    140            avatar_tag: None,
    141            unlock_method: unlock,
    142            last_used_at: last_used.map(|s| s.to_string()),
    143            is_active: false,
    144        }
    145    }
    146
    147    /// UT: the pre-profiles install — one account, no PIN — never sees a picker.
    148    #[test]
    149    fn single_pinless_profile_resumes() {
    150        let profiles = vec![profile(
    151            "u1",
    152            UnlockMethod::None,
    153            Some("2026-01-01T00:00:00Z"),
    154        )];
    155        assert_eq!(
    156            startup_target(&profiles, true),
    157            StartupTarget::Resume {
    158                user_id: "u1".to_string()
    159            },
    160            "a lone profile resumes even with the setting on"
    161        );
    162    }
    163
    164    /// UT: a PIN is not skippable by relaunching the app.
    165    #[test]
    166    fn pinned_last_profile_always_asks() {
    167        let profiles = vec![profile(
    168            "u1",
    169            UnlockMethod::Pin,
    170            Some("2026-01-01T00:00:00Z"),
    171        )];
    172        assert_eq!(startup_target(&profiles, false), StartupTarget::Picker);
    173    }
    174
    175    /// UT: several pinless profiles resume the last one unless asked to ask.
    176    #[test]
    177    fn multiple_profiles_follow_the_setting() {
    178        let profiles = vec![
    179            profile("u1", UnlockMethod::None, Some("2026-01-01T00:00:00Z")),
    180            profile("u2", UnlockMethod::None, Some("2026-02-01T00:00:00Z")),
    181        ];
    182        assert_eq!(
    183            startup_target(&profiles, false),
    184            StartupTarget::Resume {
    185                user_id: "u2".to_string()
    186            },
    187            "resumes the most recently used"
    188        );
    189        assert_eq!(startup_target(&profiles, true), StartupTarget::Picker);
    190    }
    191
    192    /// UT: nothing signed in yet.
    193    #[test]
    194    fn no_profiles_shows_the_picker() {
    195        assert_eq!(startup_target(&[], false), StartupTarget::Picker);
    196    }
    197}