feat(profiles): multi-user profiles with PIN switching
A shared device can hold several accounts from the same server and switch between them in a couple of taps. A profile can be locked behind a 4-8 digit PIN; one without a PIN is one tap away. Forgetting a PIN falls through to the account's own Jellyfin password, so there is no reset flow and no recovery secret to store. Opt-in by construction: a single account with no PIN starts, plays and downloads exactly as before, and never sees a picker. Two decisions worth keeping: - Switching is not logging out. auth_logout invalidates the token server-side, which is precisely what a switch must not do, or every switch back would cost a password. The switch runs as a plan (profiles/switch.rs) so the teardown *ordering* is unit-testable with no player and no server -- a straggler reporting after the active user flips would attribute one account's viewing to another, silently. - The PIN gates switching, not the token at rest. Wrapping each token with its PIN would leave a locked profile unable to resume its own downloads or drain its own sync queue until somebody typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. auth_initialize does refuse to restore a PIN-protected session, so the gate is on the session rather than on which screen is shown. "Child account" is not modelled anywhere -- a child's profile is simply one with no PIN. The frontend renders an opaque unlockMethod and never compares a PIN, counts an attempt or infers a role. Migration 024 adds user_pins, user_item_visibility, user_libraries and download_grants, and backfills the existing user so an upgrade does not blank its library. The visibility and grant tables are the schema half of the cache-scoping and shared-download work; the read-path enforcement is still to come (see docs/specs/multi-user-profiles.md).
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
//! Profile switch orchestration, as a plan rather than a procedure.
|
||||
//!
|
||||
//! Switching profiles tears down and rebuilds nearly everything the app holds:
|
||||
//! the player and its queue, the sync queue drain, the session poller, the
|
||||
//! lockscreen metadata, the repository handle. The *ordering* of that teardown
|
||||
//! is a correctness invariant, not an implementation detail — a straggler that
|
||||
//! reports after the active user has flipped attributes one account's viewing to
|
||||
//! another, which is silent, plausible-looking, and unrecoverable.
|
||||
//!
|
||||
//! So the ordering lives here as a pure function returning a list of steps, and
|
||||
//! the command layer executes them. That is the only way this gets tested: an
|
||||
//! end-to-end switch needs two real accounts on a real server, which CI does not
|
||||
//! have and never will. The plan needs nothing.
|
||||
//!
|
||||
//! Two hazards worth remembering while executing a plan, both already paid for
|
||||
//! elsewhere in this codebase (see CLAUDE.md): never call a blocking API from a
|
||||
//! player event callback, and never hold a lock across a `match` scrutinee. A
|
||||
//! teardown reaches every one of those paths at once, from a new direction.
|
||||
//!
|
||||
//! TRACES: UR-082 | DR-270
|
||||
|
||||
/// One executable step of a switch.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SwitchStep {
|
||||
/// Stop playback and drop the queue. The queue cannot outlive its owner.
|
||||
StopPlayback,
|
||||
/// Flush what the outgoing profile changed while offline, so it is not
|
||||
/// replayed under the incoming profile's token.
|
||||
ParkSyncQueue {
|
||||
user_id: String,
|
||||
},
|
||||
StopSessionPoller,
|
||||
/// Clear OS media metadata so the lockscreen does not show the outgoing
|
||||
/// profile's episode to whoever just took over the device.
|
||||
ClearLockscreenMetadata,
|
||||
DestroyRepository,
|
||||
/// The point of no return: after this, writes land under the new profile.
|
||||
SetActiveUser {
|
||||
user_id: String,
|
||||
},
|
||||
BuildRepository {
|
||||
user_id: String,
|
||||
},
|
||||
StartSessionPoller,
|
||||
/// Re-derive what the server currently lets this profile see. Only possible
|
||||
/// online; offline the cached view stays as it was, which is stale-permissive
|
||||
/// by design.
|
||||
RefreshVisibility {
|
||||
user_id: String,
|
||||
},
|
||||
EmitSwitched {
|
||||
user_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Build the ordered plan for moving from `from` to `to`.
|
||||
///
|
||||
/// Switching to the profile that is already active is not a no-op — it is how an
|
||||
/// idle re-lock is dismissed — but it must not tear down playback, or unlocking
|
||||
/// your own screen would stop the music. Only the emit survives.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
pub fn plan(from: Option<&str>, to: &str, online: bool) -> Vec<SwitchStep> {
|
||||
if from == Some(to) {
|
||||
return vec![SwitchStep::EmitSwitched {
|
||||
user_id: to.to_string(),
|
||||
}];
|
||||
}
|
||||
|
||||
let mut steps = Vec::new();
|
||||
|
||||
if let Some(outgoing) = from {
|
||||
steps.push(SwitchStep::StopPlayback);
|
||||
steps.push(SwitchStep::ParkSyncQueue {
|
||||
user_id: outgoing.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::StopSessionPoller);
|
||||
steps.push(SwitchStep::ClearLockscreenMetadata);
|
||||
steps.push(SwitchStep::DestroyRepository);
|
||||
}
|
||||
|
||||
steps.push(SwitchStep::SetActiveUser {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::BuildRepository {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::StartSessionPoller);
|
||||
|
||||
if online {
|
||||
steps.push(SwitchStep::RefreshVisibility {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
steps.push(SwitchStep::EmitSwitched {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
|
||||
steps
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn index_of(steps: &[SwitchStep], want: &SwitchStep) -> usize {
|
||||
steps
|
||||
.iter()
|
||||
.position(|s| s == want)
|
||||
.unwrap_or_else(|| panic!("step {:?} missing from plan {:?}", want, steps))
|
||||
}
|
||||
|
||||
/// UT: the invariant that prevents misattributed playback reports —
|
||||
/// everything belonging to the outgoing profile is torn down *before* the
|
||||
/// active user flips.
|
||||
#[test]
|
||||
fn teardown_precedes_the_flip() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
let flip = index_of(
|
||||
&steps,
|
||||
&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(index_of(&steps, &SwitchStep::StopPlayback) < flip);
|
||||
assert!(
|
||||
index_of(
|
||||
&steps,
|
||||
&SwitchStep::ParkSyncQueue {
|
||||
user_id: "dad".to_string()
|
||||
}
|
||||
) < flip
|
||||
);
|
||||
assert!(index_of(&steps, &SwitchStep::StopSessionPoller) < flip);
|
||||
assert!(index_of(&steps, &SwitchStep::DestroyRepository) < flip);
|
||||
}
|
||||
|
||||
/// UT: the outgoing profile's queued offline mutations are parked under
|
||||
/// *its* id, never the incoming one's.
|
||||
#[test]
|
||||
fn sync_queue_is_parked_for_the_outgoing_profile() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
assert!(steps.contains(&SwitchStep::ParkSyncQueue {
|
||||
user_id: "dad".to_string()
|
||||
}));
|
||||
assert!(!steps.contains(&SwitchStep::ParkSyncQueue {
|
||||
user_id: "kid".to_string()
|
||||
}));
|
||||
}
|
||||
|
||||
/// UT: the repository is rebuilt only after the flip, so it cannot be
|
||||
/// constructed against a user id that is about to change.
|
||||
#[test]
|
||||
fn repository_is_rebuilt_after_the_flip() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
let flip = index_of(
|
||||
&steps,
|
||||
&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
index_of(
|
||||
&steps,
|
||||
&SwitchStep::BuildRepository {
|
||||
user_id: "kid".to_string()
|
||||
}
|
||||
) > flip
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: the switch is announced last, so nothing observing the event can
|
||||
/// catch the app mid-teardown.
|
||||
#[test]
|
||||
fn switch_is_announced_last() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
assert_eq!(
|
||||
steps.last(),
|
||||
Some(&SwitchStep::EmitSwitched {
|
||||
user_id: "kid".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: first sign-in has nothing to tear down.
|
||||
#[test]
|
||||
fn cold_start_only_builds_up() {
|
||||
let steps = plan(None, "kid", true);
|
||||
assert!(!steps.contains(&SwitchStep::StopPlayback));
|
||||
assert!(!steps.contains(&SwitchStep::DestroyRepository));
|
||||
assert_eq!(
|
||||
steps.first(),
|
||||
Some(&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: offline, visibility cannot be re-derived — the server is not there to
|
||||
/// say what this profile may see, and guessing would be worse than stale.
|
||||
#[test]
|
||||
fn offline_skips_visibility_refresh() {
|
||||
let steps = plan(Some("dad"), "kid", false);
|
||||
assert!(!steps
|
||||
.iter()
|
||||
.any(|s| matches!(s, SwitchStep::RefreshVisibility { .. })));
|
||||
}
|
||||
|
||||
/// UT: dismissing an idle re-lock on your own profile must not stop the
|
||||
/// music you were listening to.
|
||||
#[test]
|
||||
fn unlocking_the_same_profile_does_not_disturb_playback() {
|
||||
let steps = plan(Some("dad"), "dad", true);
|
||||
assert_eq!(
|
||||
steps,
|
||||
vec![SwitchStep::EmitSwitched {
|
||||
user_id: "dad".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user