//! Multi-user profiles on one device. //! //! A "profile" is an account on the *currently connected* Jellyfin server that //! this device has signed into at least once. The rows have existed since the //! first schema (`users`), and so have `storage_get_users` / //! `storage_set_active_user`; what was missing was never the storage but the //! decision of who may switch to what — which is domain logic, and stays here. //! //! Two things this module is careful about: //! //! - **Switching is not logging out.** `auth_logout` calls Jellyfin's logout //! endpoint, which invalidates the token server-side. That is precisely the //! behaviour a switch must not have, or every switch back would need a //! password. Nothing here calls it. //! - **"Child account" is not modelled.** A child's profile is simply one with //! no PIN. The frontend receives an opaque [`UnlockMethod`] and renders it; it //! never infers a role, and no role taxonomy is invented on either side. //! //! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268 pub mod pin; pub mod store; pub mod switch; use serde::{Deserialize, Serialize}; /// How a profile is entered. /// /// Deliberately not "adult"/"child": the app has no way to know a person's age /// and no business encoding one. It knows whether a code is set. /// /// TRACES: UR-083 | DR-276 #[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum UnlockMethod { /// One tap. No code set. None, /// A numeric code gates the switch. Pin, } /// A switchable account on this device. /// /// TRACES: UR-082 | DR-267 #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Profile { pub user_id: String, pub username: String, pub server_id: String, /// Jellyfin's primary-image tag, for the tile. `None` renders initials. pub avatar_tag: Option, pub unlock_method: UnlockMethod, pub last_used_at: Option, pub is_active: bool, } /// The result of an unlock attempt. /// /// Note the explicit field renames. tauri-specta emits tagged-union *fields* /// with their Rust names rather than camelCasing them, so a field that would /// differ between the two conventions is renamed here by hand — the same trap /// that produced `new_url` on the frontend once already. /// /// TRACES: UR-083, UR-084 | DR-268, DR-269 #[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum UnlockOutcome { /// Switched. The profile is now active. Ok { #[serde(rename = "userId")] user_id: String, }, /// Wrong code, attempts left. WrongPin { #[serde(rename = "attemptsRemaining")] attempts_remaining: u32, }, /// Too many wrong codes; refused until this RFC3339 instant. LockedOut { until: String }, /// No code is recoverable from here — sign in with the account password. NeedsPassword, } /// What the app should do when it starts. /// /// The decision is backend state (profile count, PIN presence, a stored /// setting), so the frontend asks rather than computes. A single account with no /// PIN always resumes, which is what keeps this feature invisible until it is /// wanted. /// /// TRACES: UR-082 | DR-274 #[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum StartupTarget { /// Resume this profile without asking. Resume { #[serde(rename = "userId")] user_id: String, }, /// Show the picker. Picker, } /// Decide the startup target from the profiles present and the user's setting. /// /// Pure, because the rule is worth testing and the inputs are trivial to state: /// /// - No profiles at all → picker (which renders as the first-run login). /// - The last-used profile has a PIN → picker, regardless of the setting. A code /// that could be skipped by relaunching is not a code. /// - More than one profile and "ask who's watching" is on → picker. /// - Otherwise → resume, exactly as the app behaved before profiles existed. /// /// TRACES: UR-082 | DR-274 pub fn startup_target(profiles: &[Profile], ask_on_start: bool) -> StartupTarget { let last_used = profiles .iter() .max_by(|a, b| a.last_used_at.cmp(&b.last_used_at)); match last_used { None => StartupTarget::Picker, Some(p) if p.unlock_method == UnlockMethod::Pin => StartupTarget::Picker, Some(_) if ask_on_start && profiles.len() > 1 => StartupTarget::Picker, Some(p) => StartupTarget::Resume { user_id: p.user_id.clone(), }, } } #[cfg(test)] mod tests { use super::*; fn profile(id: &str, unlock: UnlockMethod, last_used: Option<&str>) -> Profile { Profile { user_id: id.to_string(), username: id.to_string(), server_id: "server-1".to_string(), avatar_tag: None, unlock_method: unlock, last_used_at: last_used.map(|s| s.to_string()), is_active: false, } } /// UT: the pre-profiles install — one account, no PIN — never sees a picker. #[test] fn single_pinless_profile_resumes() { let profiles = vec![profile( "u1", UnlockMethod::None, Some("2026-01-01T00:00:00Z"), )]; assert_eq!( startup_target(&profiles, true), StartupTarget::Resume { user_id: "u1".to_string() }, "a lone profile resumes even with the setting on" ); } /// UT: a PIN is not skippable by relaunching the app. #[test] fn pinned_last_profile_always_asks() { let profiles = vec![profile( "u1", UnlockMethod::Pin, Some("2026-01-01T00:00:00Z"), )]; assert_eq!(startup_target(&profiles, false), StartupTarget::Picker); } /// UT: several pinless profiles resume the last one unless asked to ask. #[test] fn multiple_profiles_follow_the_setting() { let profiles = vec![ profile("u1", UnlockMethod::None, Some("2026-01-01T00:00:00Z")), profile("u2", UnlockMethod::None, Some("2026-02-01T00:00:00Z")), ]; assert_eq!( startup_target(&profiles, false), StartupTarget::Resume { user_id: "u2".to_string() }, "resumes the most recently used" ); assert_eq!(startup_target(&profiles, true), StartupTarget::Picker); } /// UT: nothing signed in yet. #[test] fn no_profiles_shows_the_picker() { assert_eq!(startup_target(&[], false), StartupTarget::Picker); } }