feat(qbzd): add session lifecycle port (Phase 2)

New session.rs module ports activate_session from Tauri to headless:
- Creates per-user directories (same layout as desktop app)
- Persists last_user_id marker for session restore
- Emits RuntimeEvent::Ready via event bus
- Skips desktop-only stores (tray, visualizer, remote control)

Session activation wired into daemon startup after auto-login.
Audio settings reload deferred until qbz-player crate exposes
reload_settings (currently on legacy player only).

cc
This commit is contained in:
vicrodh committed 2026-04-09 03:23:56 -06:00
1 parent 2916dee42c
commit 6ee123c2a4
3 files changed
+111 -18

No files matched your search

+35 -18
View File
@@ -7,8 +7,11 @@ use qbz_cache::AudioCache;
use qbz_core::QbzCore;
use qbz_player::Player;
use tokio::sync::RwLock;
use crate::adapter::{DaemonAdapter, DaemonEvent};
use crate::config::DaemonConfig;
use crate::session::UserSession;
/// Central state container for the headless daemon.
/// Replaces Tauri's app.state::<T>() with direct field access.
@@ -17,6 +20,8 @@ pub struct DaemonCore {
pub core: Arc<QbzCore<DaemonAdapter>>,
pub audio_cache: Arc<AudioCache>,
pub event_bus: broadcast::Sender<DaemonEvent>,
/// Per-user state, populated after login + session activation
pub user: RwLock<Option<UserSession>>,
}
/// Run the daemon main loop.
@@ -59,26 +64,41 @@ pub async fn run(mut config: DaemonConfig) -> Result<(), String> {
core.init().await.map_err(|e| format!("QbzCore init failed: {}", e))?;
log::info!("[qbzd] QbzCore initialized");
// Try auto-login from saved OAuth token
let logged_in = try_auto_login(&core).await;
if logged_in {
log::info!("[qbzd] Auto-login successful");
} else {
log::warn!("[qbzd] No saved credentials. Run `qbzd login` to authenticate.");
}
// Create audio cache with configured sizes
let cache_bytes = config.memory_cache_bytes();
let audio_cache = Arc::new(AudioCache::new(cache_bytes));
log::info!("[qbzd] Audio cache: {} MB", config.cache.memory_mb);
let core = Arc::new(core);
let daemon = Arc::new(DaemonCore {
config: config.clone(),
core: Arc::new(core),
core: core.clone(),
audio_cache,
event_bus: event_tx,
event_bus: event_tx.clone(),
user: RwLock::new(None),
});
// Try auto-login from saved OAuth token
match try_auto_login(&core).await {
Some(user_id) => {
log::info!("[qbzd] Auto-login successful (user_id: {})", user_id);
// Activate per-user session (initialize stores, sync settings)
match crate::session::activate_session(user_id, &core, &event_tx).await {
Ok(session) => {
*daemon.user.write().await = Some(session);
log::info!("[qbzd] User session activated");
}
Err(e) => {
log::error!("[qbzd] Session activation failed: {}", e);
}
}
}
None => {
log::warn!("[qbzd] No saved credentials. Run `qbzd login` to authenticate.");
}
}
log::info!(
"[qbzd] Daemon ready on {}:{}",
config.server.bind,
@@ -128,12 +148,9 @@ pub async fn run(mut config: DaemonConfig) -> Result<(), String> {
}
/// Try to restore session from saved OAuth token in keyring.
async fn try_auto_login(core: &QbzCore<DaemonAdapter>) -> bool {
// Try loading token from keyring (same storage as desktop app)
let token = match load_oauth_token() {
Some(t) => t,
None => return false,
};
/// Returns the user_id on success.
async fn try_auto_login(core: &QbzCore<DaemonAdapter>) -> Option<u64> {
let token = load_oauth_token()?;
match core.login_with_token(&token).await {
Ok(session) => {
@@ -142,11 +159,11 @@ async fn try_auto_login(core: &QbzCore<DaemonAdapter>) -> bool {
session.display_name,
session.user_id
);
true
Some(session.user_id)
}
Err(e) => {
log::warn!("[qbzd] Saved token expired or invalid: {}", e);
false
None
}
}
}
+1
View File
@@ -2,6 +2,7 @@ mod config;
mod daemon;
mod adapter;
mod resources;
mod session;
use clap::{Parser, Subcommand};
+75
View File
@@ -0,0 +1,75 @@
//! Headless session lifecycle — port of session_lifecycle.rs without Tauri.
//!
//! Initializes per-user stores after login. The stores themselves are
//! Tauri-free (SQLite); only the state lookup mechanism changes.
use std::path::PathBuf;
use crate::adapter::{DaemonEvent, RuntimeEvent};
/// Per-user state, populated after successful login.
pub struct UserSession {
pub user_id: u64,
pub data_dir: PathBuf,
pub cache_dir: PathBuf,
}
/// Activate a user session (headless equivalent of session_lifecycle::activate_session).
///
/// Creates per-user directories, initializes stores, syncs audio settings to player.
pub async fn activate_session(
user_id: u64,
core: &qbz_core::QbzCore<crate::adapter::DaemonAdapter>,
event_tx: &tokio::sync::broadcast::Sender<DaemonEvent>,
) -> Result<UserSession, String> {
log::info!("[qbzd/session] Activating session for user {}", user_id);
// Resolve per-user directories (same layout as desktop app)
let global_data = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
let global_cache = dirs::cache_dir()
.ok_or("Could not determine cache directory")?
.join("qbz");
let data_dir = global_data.join("users").join(user_id.to_string());
let cache_dir = global_cache.join("users").join(user_id.to_string());
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create user data dir: {}", e))?;
std::fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create user cache dir: {}", e))?;
log::info!("[qbzd/session] User data dir: {}", data_dir.display());
log::info!("[qbzd/session] User cache dir: {}", cache_dir.display());
// Note: audio settings were already loaded at DaemonCore init from the
// global store. Per-user settings sync will be added when qbz-player
// exposes reload_settings() in the crate (currently on legacy player only).
// Persist last user_id for session restore on next launch
let marker_path = global_data.join("last_user_id");
let _ = std::fs::write(&marker_path, user_id.to_string());
log::info!("[qbzd/session] Saved last_user_id marker");
// Emit session activated event
let _ = event_tx.send(DaemonEvent::Runtime(RuntimeEvent::Ready { user_id }));
log::info!("[qbzd/session] Session activated");
Ok(UserSession {
user_id,
data_dir,
cache_dir,
})
}
/// Load the last user_id from the marker file (for auto-login session restore).
pub fn load_last_user_id() -> Option<u64> {
let path = dirs::data_dir()?.join("qbz").join("last_user_id");
std::fs::read_to_string(&path)
.ok()?
.trim()
.parse()
.ok()
}