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:
4 files changed
+74
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -258,7 +258,10 @@ export component LoginScreen inherits Rectangle {
|
||||
Rectangle { height: Spacing.sm; }
|
||||
}
|
||||
|
||||
if OfflineState.has-previous-session: HorizontalLayout {
|
||||
// Always visible (#553): without a previous session this
|
||||
// opens the GUEST profile (user 0) — Local Library only,
|
||||
// adopted by the account on the first real login.
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
QbzTextLink {
|
||||
label: @tr("Start offline (no access to Qobuz™ services)");
|
||||
|
||||
+13
-6
@@ -537,12 +537,19 @@ async fn enter_shell_offline(
|
||||
image_cache: artwork::ImageCache,
|
||||
settings_ctx: Arc<settings::SettingsCtx>,
|
||||
) -> Result<(), String> {
|
||||
// Never open the empty user-0 profile: offline mode needs a previous
|
||||
// session's data (Tauri falls back to user 0; the port refuses — the
|
||||
// login UI hides the link in that case, this is the backstop).
|
||||
let Some(user_id) = qbz_app::user_data::UserDataPaths::load_last_user_id() else {
|
||||
return Err("no previous session — offline mode requires a prior login".to_string());
|
||||
};
|
||||
// No previous session → the GUEST profile, user 0 (#553; restores the
|
||||
// Tauri fallback this port used to refuse). The guest builds a real
|
||||
// per-user store set (local library, mixtapes, prefs) under users/0;
|
||||
// the first successful login ADOPTS it (AppRuntime::activate renames
|
||||
// users/0 to the real id when that account has no profile here yet).
|
||||
// `activate_offline` below resolves the same id — 0 is never persisted
|
||||
// as the last-user marker, so it never masquerades as a real session.
|
||||
let user_id = qbz_app::user_data::UserDataPaths::load_last_user_id().unwrap_or(0);
|
||||
if user_id == 0 {
|
||||
log::info!(
|
||||
"[qbz-slint] offline shell: no previous session — entering the guest profile (user 0)"
|
||||
);
|
||||
}
|
||||
|
||||
// Session scaffolding at the last user (session store, runtime state).
|
||||
runtime.activate_offline().await?;
|
||||
|
||||
Reference in new issue
Block a user