Merge refactor/qbz-app-session-activation: minimal session activation
cc
This commit is contained in:
1 file changed
+233
-30
+233
-30
@@ -1,26 +1,45 @@
|
|||||||
//! Framework-agnostic application runtime facade.
|
//! Framework-agnostic application runtime facade.
|
||||||
//!
|
//!
|
||||||
//! [`AppRuntime`] is the composition root that a non-Tauri UI shell (Slint,
|
//! [`AppRuntime`] is the composition root that a non-Tauri UI shell (Slint,
|
||||||
//! TUI, headless) builds on. It owns an `Arc<QbzCore<A>>` plus the
|
//! TUI, headless) builds on. It owns an `Arc<QbzCore<A>>`, the framework-
|
||||||
//! framework-agnostic runtime state machine, constructed the same way the
|
//! agnostic runtime state machine, and the per-user session, all without any
|
||||||
//! Tauri `CoreBridge` does but without any Tauri dependency.
|
//! Tauri dependency.
|
||||||
//!
|
//!
|
||||||
//! Scope of this module (Session 21 / Task 1 of the Slint POC readiness
|
//! Scope (Slint POC readiness audit, sessions 21-22):
|
||||||
//! audit): composition and accessors only. It deliberately does NOT own the
|
|
||||||
//! per-user stores or perform session activation — that is Task 2, the
|
|
||||||
//! framework-agnostic session activation spine. Until then a shell reaches
|
|
||||||
//! catalog, playback, and queue functionality through [`AppRuntime::core`].
|
|
||||||
//!
|
//!
|
||||||
//! The Tauri app does not consume this module; `CoreBridge` keeps its own
|
//! - Task 1 — composition and accessors: [`AppRuntime::new`], [`AppRuntime::core`].
|
||||||
//! construction path. `AppRuntime` is purely additive.
|
//! - Task 2 — minimal session activation: [`AppRuntime::activate`] and
|
||||||
|
//! friends. This is deliberately minimal. It opens only the session store
|
||||||
|
//! and performs the portable session scaffolding (user paths, directories,
|
||||||
|
//! last-user marker, runtime state). It does NOT touch Tauri's
|
||||||
|
//! `session_lifecycle`, does not initialize the `src-tauri`-side per-user
|
||||||
|
//! stores (`library`, `reco`, `lyrics`, ...), and does not run the
|
||||||
|
//! flat-to-user migration. A shell opens further stores per view, as the
|
||||||
|
//! views that need them come online.
|
||||||
|
//!
|
||||||
|
//! The Tauri app does not consume this module; `CoreBridge` and
|
||||||
|
//! `session_lifecycle` keep their own paths. `AppRuntime` is purely additive.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use qbz_audio::{settings::AudioSettingsStore, AudioDiagnostic, AudioSettings, VisualizerTap};
|
use qbz_audio::{settings::AudioSettingsStore, AudioDiagnostic, AudioSettings, VisualizerTap};
|
||||||
use qbz_core::{FrontendAdapter, QbzCore};
|
use qbz_core::{FrontendAdapter, QbzCore};
|
||||||
use qbz_player::Player;
|
use qbz_player::Player;
|
||||||
|
|
||||||
use crate::runtime::RuntimeManager;
|
use crate::runtime::RuntimeManager;
|
||||||
|
use crate::session_store::SessionStore;
|
||||||
|
use crate::user_data::UserDataPaths;
|
||||||
|
|
||||||
|
/// The per-user stores opened for the currently active session.
|
||||||
|
///
|
||||||
|
/// Minimal by design: it holds only the session store. A shell opens further
|
||||||
|
/// per-user stores as the views that need them come online, rather than
|
||||||
|
/// loading the full WebKit-era store set up front.
|
||||||
|
struct ActiveSession {
|
||||||
|
user_id: u64,
|
||||||
|
session_store: SessionStore,
|
||||||
|
}
|
||||||
|
|
||||||
/// Composition root for a non-Tauri UI shell.
|
/// Composition root for a non-Tauri UI shell.
|
||||||
///
|
///
|
||||||
@@ -29,6 +48,8 @@ use crate::runtime::RuntimeManager;
|
|||||||
pub struct AppRuntime<A: FrontendAdapter + Send + Sync + 'static> {
|
pub struct AppRuntime<A: FrontendAdapter + Send + Sync + 'static> {
|
||||||
core: Arc<QbzCore<A>>,
|
core: Arc<QbzCore<A>>,
|
||||||
runtime: Arc<RuntimeManager>,
|
runtime: Arc<RuntimeManager>,
|
||||||
|
user_paths: UserDataPaths,
|
||||||
|
session: Mutex<Option<ActiveSession>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
||||||
@@ -48,6 +69,8 @@ impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
|||||||
Self {
|
Self {
|
||||||
core: Arc::new(core),
|
core: Arc::new(core),
|
||||||
runtime: Arc::new(RuntimeManager::new()),
|
runtime: Arc::new(RuntimeManager::new()),
|
||||||
|
user_paths: UserDataPaths::new(),
|
||||||
|
session: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,34 +113,149 @@ impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
|||||||
pub fn runtime(&self) -> &Arc<RuntimeManager> {
|
pub fn runtime(&self) -> &Arc<RuntimeManager> {
|
||||||
&self.runtime
|
&self.runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Session activation (Task 2) ====================
|
||||||
|
|
||||||
|
/// Activate the per-user session against explicit directories.
|
||||||
|
///
|
||||||
|
/// This is the testable core of session activation. It creates the
|
||||||
|
/// directories, opens the session store, and marks the runtime state
|
||||||
|
/// machine as session-activated. It performs no global-path writes (no
|
||||||
|
/// `last_user_id` marker) and does not touch [`UserDataPaths`] state, so
|
||||||
|
/// tests and shells managing their own paths can call it directly.
|
||||||
|
pub async fn activate_at(
|
||||||
|
&self,
|
||||||
|
user_id: u64,
|
||||||
|
data_dir: &Path,
|
||||||
|
cache_dir: &Path,
|
||||||
|
) -> Result<(), 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))?;
|
||||||
|
|
||||||
|
let session_store = SessionStore::new_at(data_dir)?;
|
||||||
|
|
||||||
|
self.runtime.set_session_activated(true, user_id).await;
|
||||||
|
|
||||||
|
let mut guard = self
|
||||||
|
.session
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("session lock poisoned: {}", e))?;
|
||||||
|
*guard = Some(ActiveSession {
|
||||||
|
user_id,
|
||||||
|
session_store,
|
||||||
|
});
|
||||||
|
|
||||||
|
log::info!("[AppRuntime] Session activated for user");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate the per-user session for `user_id`.
|
||||||
|
///
|
||||||
|
/// Resolves the real per-user directories through [`UserDataPaths`],
|
||||||
|
/// 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.user_paths.set_user(user_id);
|
||||||
|
let data_dir = self.user_paths.user_data_dir()?;
|
||||||
|
let cache_dir = self.user_paths.user_cache_dir()?;
|
||||||
|
self.activate_at(user_id, &data_dir, &cache_dir).await?;
|
||||||
|
UserDataPaths::save_last_user_id(user_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate an offline-only session using the last known user.
|
||||||
|
///
|
||||||
|
/// Falls back to user id `0` (an empty profile) when no previous session
|
||||||
|
/// was recorded. Does not re-persist the last-user marker.
|
||||||
|
pub async fn activate_offline(&self) -> Result<(), String> {
|
||||||
|
let user_id = UserDataPaths::load_last_user_id().unwrap_or(0);
|
||||||
|
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()?;
|
||||||
|
self.activate_at(user_id, &data_dir, &cache_dir).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deactivate the current session.
|
||||||
|
///
|
||||||
|
/// Drops the open per-user stores (closing their database connections),
|
||||||
|
/// clears the active user, and resets the runtime state machine. The
|
||||||
|
/// `last_user_id` marker is intentionally kept on disk so a later
|
||||||
|
/// offline session can still find the user's data.
|
||||||
|
pub async fn deactivate(&self) -> Result<(), String> {
|
||||||
|
{
|
||||||
|
let mut guard = self
|
||||||
|
.session
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("session lock poisoned: {}", e))?;
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
self.user_paths.clear_user();
|
||||||
|
self.runtime.set_session_activated(false, 0).await;
|
||||||
|
log::info!("[AppRuntime] Session deactivated");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a per-user session is currently active.
|
||||||
|
pub fn is_session_active(&self) -> bool {
|
||||||
|
self.session
|
||||||
|
.lock()
|
||||||
|
.map(|guard| guard.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active user id, if a session is active.
|
||||||
|
pub fn active_user_id(&self) -> Option<u64> {
|
||||||
|
self.session
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|guard| guard.as_ref().map(|s| s.user_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a closure with the active session store.
|
||||||
|
///
|
||||||
|
/// Returns `None` when no session is active. This hands the shell the
|
||||||
|
/// real [`SessionStore`] API without duplicating its methods on the
|
||||||
|
/// facade.
|
||||||
|
pub fn with_session_store<F, R>(&self, f: F) -> Option<R>
|
||||||
|
where
|
||||||
|
F: FnOnce(&SessionStore) -> R,
|
||||||
|
{
|
||||||
|
let guard = self.session.lock().ok()?;
|
||||||
|
guard.as_ref().map(|s| f(&s.session_store))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::runtime::RuntimeState;
|
use crate::runtime::RuntimeState;
|
||||||
|
use crate::session_store::PersistedSessionSnapshot;
|
||||||
use qbz_core::NoOpAdapter;
|
use qbz_core::NoOpAdapter;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
static NONCE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let nonce = NONCE.fetch_add(1, Ordering::Relaxed);
|
||||||
|
std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_runtime() -> AppRuntime<NoOpAdapter> {
|
||||||
|
AppRuntime::with_audio_settings(NoOpAdapter, None, AudioSettings::default(), None)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_with_explicit_audio_settings() {
|
fn builds_with_explicit_audio_settings() {
|
||||||
let rt = AppRuntime::with_audio_settings(
|
let rt = test_runtime();
|
||||||
NoOpAdapter,
|
|
||||||
None,
|
|
||||||
AudioSettings::default(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
// The core handle is reachable without panicking.
|
|
||||||
let _core = rt.core();
|
let _core = rt.core();
|
||||||
|
assert!(!rt.is_session_active());
|
||||||
|
assert_eq!(rt.active_user_id(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn runtime_state_machine_starts_uninitialized() {
|
async fn runtime_state_machine_starts_uninitialized() {
|
||||||
let rt = AppRuntime::with_audio_settings(
|
let rt = test_runtime();
|
||||||
NoOpAdapter,
|
|
||||||
None,
|
|
||||||
AudioSettings::default(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rt.runtime().get_status().await.state,
|
rt.runtime().get_status().await.state,
|
||||||
RuntimeState::Uninitialized
|
RuntimeState::Uninitialized
|
||||||
@@ -126,13 +264,78 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn core_reports_no_session_before_login() {
|
async fn core_reports_no_session_before_login() {
|
||||||
let rt = AppRuntime::with_audio_settings(
|
let rt = test_runtime();
|
||||||
NoOpAdapter,
|
|
||||||
None,
|
|
||||||
AudioSettings::default(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert!(!rt.core().has_session().await);
|
assert!(!rt.core().has_session().await);
|
||||||
assert!(!rt.core().is_api_initialized().await);
|
assert!(!rt.core().is_api_initialized().await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn activate_at_opens_session_and_marks_runtime() {
|
||||||
|
let rt = test_runtime();
|
||||||
|
let data_dir = unique_test_dir("activate-data");
|
||||||
|
let cache_dir = unique_test_dir("activate-cache");
|
||||||
|
|
||||||
|
rt.activate_at(42, &data_dir, &cache_dir)
|
||||||
|
.await
|
||||||
|
.expect("activation succeeds");
|
||||||
|
|
||||||
|
assert!(rt.is_session_active());
|
||||||
|
assert_eq!(rt.active_user_id(), Some(42));
|
||||||
|
assert!(rt.runtime().get_status().await.session_activated);
|
||||||
|
assert!(data_dir.join("session.db").exists());
|
||||||
|
assert!(cache_dir.exists());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&data_dir);
|
||||||
|
let _ = std::fs::remove_dir_all(&cache_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deactivate_clears_session_and_runtime() {
|
||||||
|
let rt = test_runtime();
|
||||||
|
let data_dir = unique_test_dir("deactivate-data");
|
||||||
|
let cache_dir = unique_test_dir("deactivate-cache");
|
||||||
|
|
||||||
|
rt.activate_at(7, &data_dir, &cache_dir)
|
||||||
|
.await
|
||||||
|
.expect("activation succeeds");
|
||||||
|
rt.deactivate().await.expect("deactivation succeeds");
|
||||||
|
|
||||||
|
assert!(!rt.is_session_active());
|
||||||
|
assert_eq!(rt.active_user_id(), None);
|
||||||
|
assert!(!rt.runtime().get_status().await.session_activated);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&data_dir);
|
||||||
|
let _ = std::fs::remove_dir_all(&cache_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn with_session_store_round_trips_through_active_session() {
|
||||||
|
let rt = test_runtime();
|
||||||
|
let data_dir = unique_test_dir("store-data");
|
||||||
|
let cache_dir = unique_test_dir("store-cache");
|
||||||
|
|
||||||
|
// No session yet: closure is not run.
|
||||||
|
assert!(rt.with_session_store(|_| ()).is_none());
|
||||||
|
|
||||||
|
rt.activate_at(1, &data_dir, &cache_dir)
|
||||||
|
.await
|
||||||
|
.expect("activation succeeds");
|
||||||
|
|
||||||
|
let snapshot = PersistedSessionSnapshot::default();
|
||||||
|
rt.with_session_store(|store| store.save_session(&snapshot))
|
||||||
|
.expect("session is active")
|
||||||
|
.expect("save succeeds");
|
||||||
|
|
||||||
|
let loaded = rt
|
||||||
|
.with_session_store(|store| store.load_session())
|
||||||
|
.expect("session is active")
|
||||||
|
.expect("load succeeds");
|
||||||
|
assert_eq!(
|
||||||
|
loaded.playback.queue_tracks.len(),
|
||||||
|
snapshot.playback.queue_tracks.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&data_dir);
|
||||||
|
let _ = std::fs::remove_dir_all(&cache_dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user