feat(session): guest local-only profile — offline mode without a prior login

The 'Start offline' link was gated on has-previous-session and the
shell backstop refused to open without a recorded user, so a user who
never logged in (e.g. a purchases-only Qobuz account, #553) could not
use the app at all. The link is now always visible and the offline
shell falls back to the GUEST profile — user 0, the Tauri-era fallback
this port used to refuse — building a real per-user store set (local
library, mixtapes, prefs) under users/0. AppRuntime::activate_offline
already resolved user 0 and never persists it as the last-user marker,
so the guest never masquerades as a real session.

First real login takes possession: AppRuntime::activate renames the
users/0 data+cache dirs to the account's id when that account has no
profile on this machine yet (existing profiles always win — the guest
dirs stay parked, no merge). Best-effort, login never breaks on a
failed rename.

cc
This commit is contained in:
vicrodh committed 2026-07-06 21:47:00 -06:00
1 parent cb48e04395
commit b6e56e3b47
4 files changed
+74 -7

No files matched your search

+40
View File
@@ -193,6 +193,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
/// activates against them, and persists the last-user marker so the
/// session can be restored on the next launch.
pub async fn activate(&self, user_id: u64) -> Result<(), String> {
Self::adopt_guest_profile(user_id);
self.user_paths.set_user(user_id);
let data_dir = self.user_paths.user_data_dir()?;
let cache_dir = self.user_paths.user_cache_dir()?;
@@ -201,6 +202,45 @@ impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
Ok(())
}
/// First real login takes possession of the guest profile (#553): data
/// built while logged off (local library, mixtapes, per-user prefs —
/// `users/0/`) is renamed to this account IF the account has no profile
/// on this machine yet. An existing profile always wins — the guest
/// dirs then stay parked at user 0, no merge is attempted. Best-effort:
/// a failed rename logs and falls through to a fresh profile; login
/// must never break here.
fn adopt_guest_profile(user_id: u64) {
if user_id == 0 {
return;
}
for (kind, guest, target) in [
(
"data",
UserDataPaths::data_dir_for(0),
UserDataPaths::data_dir_for(user_id),
),
(
"cache",
UserDataPaths::cache_dir_for(0),
UserDataPaths::cache_dir_for(user_id),
),
] {
let (Ok(guest), Ok(target)) = (guest, target) else {
continue;
};
if guest.is_dir() && !target.exists() {
match std::fs::rename(&guest, &target) {
Ok(()) => {
log::info!("[AppRuntime] guest profile {kind} adopted by the new session")
}
Err(e) => log::warn!(
"[AppRuntime] guest profile {kind} adoption failed ({e}); starting fresh"
),
}
}
}
}
/// Activate an offline-only session using the last known user.
///
/// Falls back to user id `0` (an empty profile) when no previous session
+17
View File
@@ -82,6 +82,23 @@ impl UserDataPaths {
Ok(base)
}
/// Data directory for an ARBITRARY user id (no active-user requirement):
/// ~/.local/share/qbz/users/{uid}/ — the same layout `user_data_dir`
/// resolves for the active user. Used by the guest-profile adoption
/// (#553), which must compare two users' paths before either is active.
pub fn data_dir_for(user_id: u64) -> Result<PathBuf, String> {
Ok(Self::global_data_dir()?
.join("users")
.join(user_id.to_string()))
}
/// Cache twin of [`Self::data_dir_for`]: ~/.cache/qbz/users/{uid}/.
pub fn cache_dir_for(user_id: u64) -> Result<PathBuf, String> {
Ok(Self::global_cache_dir()?
.join("users")
.join(user_id.to_string()))
}
/// Get the global (non-user-scoped) data directory: ~/.local/share/qbz/
pub fn global_data_dir() -> Result<PathBuf, String> {
dirs::data_dir()