refactor(qbz-app): move runtime core
This commit is contained in:
2 files changed
+409
-405
No files matched your search
@@ -1 +1,404 @@
|
||||
//! Framework-agnostic runtime primitives.
|
||||
//! Runtime Session Contract
|
||||
//!
|
||||
//! Implements the UI-agnostic runtime lifecycle as per ADR_RUNTIME_SESSION_CONTRACT.md
|
||||
//!
|
||||
//! Key concepts:
|
||||
//! - Single bootstrap entrypoint
|
||||
//! - Canonical state machine (Uninitialized -> Ready)
|
||||
//! - Typed errors (no string matching)
|
||||
//! - Command gating in backend
|
||||
//! - Lifecycle events
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Canonical runtime states
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", content = "data")]
|
||||
pub enum RuntimeState {
|
||||
/// Initial state - nothing initialized
|
||||
Uninitialized,
|
||||
/// Client initialized but no authentication
|
||||
InitializedNoAuth,
|
||||
/// Authenticated but per-user session not activated (transitional)
|
||||
AuthenticatedNoUserSession { user_id: u64 },
|
||||
/// Fully ready - all systems operational
|
||||
Ready { user_id: u64 },
|
||||
/// Degraded state - something is broken
|
||||
Degraded { reason: DegradedReason },
|
||||
}
|
||||
|
||||
impl Default for RuntimeState {
|
||||
fn default() -> Self {
|
||||
Self::Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
/// Reasons for degraded state
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "code", content = "message")]
|
||||
pub enum DegradedReason {
|
||||
/// Bundle token extraction failed
|
||||
BundleExtractionFailed(String),
|
||||
/// CoreBridge initialization failed
|
||||
CoreBridgeInitFailed(String),
|
||||
/// Network connectivity issues
|
||||
NetworkError(String),
|
||||
/// Database/storage issues
|
||||
StorageError(String),
|
||||
/// Session activation failed (per-user stores not initialized)
|
||||
SessionActivationFailed(String),
|
||||
}
|
||||
|
||||
/// Full runtime status returned by runtime_get_status and runtime_bootstrap
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeStatus {
|
||||
/// Current state
|
||||
pub state: RuntimeState,
|
||||
/// User ID if authenticated (None if not logged in)
|
||||
pub user_id: Option<u64>,
|
||||
/// Whether the API client is initialized (bundle tokens extracted)
|
||||
pub client_initialized: bool,
|
||||
/// Whether legacy auth is active
|
||||
pub legacy_auth: bool,
|
||||
/// Whether CoreBridge/V2 auth is active
|
||||
pub corebridge_auth: bool,
|
||||
/// Whether per-user session is activated
|
||||
pub session_activated: bool,
|
||||
/// Degraded reason if state is Degraded
|
||||
pub degraded_reason: Option<DegradedReason>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: RuntimeState::Uninitialized,
|
||||
user_id: None,
|
||||
client_initialized: false,
|
||||
legacy_auth: false,
|
||||
corebridge_auth: false,
|
||||
session_activated: false,
|
||||
degraded_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed runtime errors - no string matching in clients
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "code", content = "details")]
|
||||
pub enum RuntimeError {
|
||||
/// Runtime not initialized - call runtime_bootstrap first
|
||||
RuntimeNotInitialized,
|
||||
/// Authentication required for this operation
|
||||
AuthRequired,
|
||||
/// Per-user session not activated - call activate_user_session
|
||||
UserSessionNotActivated,
|
||||
/// CoreBridge/V2 auth missing - V2 commands won't work
|
||||
CoreBridgeAuthMissing,
|
||||
/// Runtime is in degraded state
|
||||
RuntimeDegraded(DegradedReason),
|
||||
/// Invalid user ID (e.g., 0)
|
||||
InvalidUserId,
|
||||
/// Bootstrap already in progress
|
||||
BootstrapInProgress,
|
||||
/// V2 CoreBridge authentication failed
|
||||
V2AuthFailed(String),
|
||||
/// V2 CoreBridge not initialized
|
||||
V2NotInitialized,
|
||||
/// Manual offline mode is on and the requested track is not available
|
||||
/// in any local cache. Emitted instead of silently streaming from the
|
||||
/// network (see issue #279).
|
||||
TrackNotAvailableOffline,
|
||||
/// Internal error
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::RuntimeNotInitialized => write!(f, "Runtime not initialized"),
|
||||
Self::AuthRequired => write!(f, "Authentication required"),
|
||||
Self::UserSessionNotActivated => write!(f, "User session not activated"),
|
||||
Self::CoreBridgeAuthMissing => write!(f, "CoreBridge authentication missing"),
|
||||
Self::RuntimeDegraded(reason) => write!(f, "Runtime degraded: {:?}", reason),
|
||||
Self::InvalidUserId => write!(f, "Invalid user ID"),
|
||||
Self::BootstrapInProgress => write!(f, "Bootstrap already in progress"),
|
||||
Self::V2AuthFailed(msg) => write!(f, "V2 authentication failed: {}", msg),
|
||||
Self::V2NotInitialized => write!(f, "V2 CoreBridge not initialized"),
|
||||
Self::TrackNotAvailableOffline => {
|
||||
write!(f, "Track not available in offline cache while in offline mode")
|
||||
}
|
||||
Self::Internal(msg) => write!(f, "Internal error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RuntimeError {}
|
||||
|
||||
/// Command prerequisites - what each command requires
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandRequirement {
|
||||
/// No requirements (public endpoints)
|
||||
None,
|
||||
/// Requires client to be initialized (bundle tokens)
|
||||
RequiresClientInit,
|
||||
/// Requires authentication (logged in)
|
||||
RequiresAuth,
|
||||
/// Requires per-user session to be activated
|
||||
RequiresUserSession,
|
||||
/// Requires CoreBridge/V2 auth (for V2 commands)
|
||||
RequiresCoreBridgeAuth,
|
||||
}
|
||||
|
||||
/// Runtime state manager - thread-safe, holds canonical state
|
||||
pub struct RuntimeManager {
|
||||
state: Arc<RwLock<RuntimeStatus>>,
|
||||
bootstrap_in_progress: Arc<RwLock<bool>>,
|
||||
/// Tracks which Mixtape/Collection the current queue was built from.
|
||||
/// Set by v2_enqueue_collection (replace mode), cleared by any
|
||||
/// non-Mixtape queue replacement (set_queue / clear_queue).
|
||||
/// Append-style ops (add/add_next/bulk) preserve this value.
|
||||
/// Persistence is in-memory only; session_queue_state table will
|
||||
/// add the source_collection_id column when it lands.
|
||||
queue_source_collection_id: RwLock<Option<String>>,
|
||||
}
|
||||
|
||||
impl RuntimeManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(RuntimeStatus::default())),
|
||||
bootstrap_in_progress: Arc::new(RwLock::new(false)),
|
||||
queue_source_collection_id: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set (or clear) which collection the current queue was built from.
|
||||
pub async fn set_queue_source_collection(&self, id: Option<String>) {
|
||||
*self.queue_source_collection_id.write().await = id;
|
||||
}
|
||||
|
||||
/// Return the collection ID that seeded the current queue, if any.
|
||||
pub async fn get_queue_source_collection(&self) -> Option<String> {
|
||||
self.queue_source_collection_id.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get current runtime status
|
||||
pub async fn get_status(&self) -> RuntimeStatus {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Update runtime state
|
||||
pub async fn set_state(&self, new_state: RuntimeState) {
|
||||
let mut status = self.state.write().await;
|
||||
status.state = new_state.clone();
|
||||
|
||||
// Update derived fields based on state
|
||||
match &new_state {
|
||||
RuntimeState::Uninitialized => {
|
||||
status.client_initialized = false;
|
||||
status.legacy_auth = false;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.user_id = None;
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::InitializedNoAuth => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = false;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.user_id = None;
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::AuthenticatedNoUserSession { user_id } => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = true;
|
||||
status.session_activated = false;
|
||||
status.user_id = Some(*user_id);
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::Ready { user_id } => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = true;
|
||||
status.corebridge_auth = true;
|
||||
status.session_activated = true;
|
||||
status.user_id = Some(*user_id);
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::Degraded { reason } => {
|
||||
status.degraded_reason = Some(reason.clone());
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[Runtime] State changed to: {:?}", new_state);
|
||||
}
|
||||
|
||||
/// Mark client as initialized
|
||||
pub async fn set_client_initialized(&self, initialized: bool) {
|
||||
let mut status = self.state.write().await;
|
||||
status.client_initialized = initialized;
|
||||
if initialized && status.state == RuntimeState::Uninitialized {
|
||||
status.state = RuntimeState::InitializedNoAuth;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark legacy auth status
|
||||
pub async fn set_legacy_auth(&self, auth: bool, user_id: Option<u64>) {
|
||||
let mut status = self.state.write().await;
|
||||
status.legacy_auth = auth;
|
||||
if auth {
|
||||
if let Some(uid) = user_id {
|
||||
status.user_id = Some(uid);
|
||||
if !status.session_activated {
|
||||
status.state = RuntimeState::AuthenticatedNoUserSession { user_id: uid };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status.user_id = None;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.state = RuntimeState::InitializedNoAuth;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark CoreBridge auth status
|
||||
pub async fn set_corebridge_auth(&self, auth: bool) {
|
||||
let mut status = self.state.write().await;
|
||||
status.corebridge_auth = auth;
|
||||
}
|
||||
|
||||
/// Mark session as activated
|
||||
pub async fn set_session_activated(&self, activated: bool, user_id: u64) {
|
||||
let mut status = self.state.write().await;
|
||||
status.session_activated = activated;
|
||||
if activated && status.legacy_auth && status.corebridge_auth {
|
||||
status.state = RuntimeState::Ready { user_id };
|
||||
status.user_id = Some(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if bootstrap is in progress
|
||||
pub async fn is_bootstrap_in_progress(&self) -> bool {
|
||||
*self.bootstrap_in_progress.read().await
|
||||
}
|
||||
|
||||
/// Set bootstrap in progress flag
|
||||
pub async fn set_bootstrap_in_progress(&self, in_progress: bool) {
|
||||
*self.bootstrap_in_progress.write().await = in_progress;
|
||||
}
|
||||
|
||||
/// Validate command requirements against current state
|
||||
pub async fn check_requirements(&self, req: CommandRequirement) -> Result<(), RuntimeError> {
|
||||
let status = self.state.read().await;
|
||||
|
||||
match req {
|
||||
CommandRequirement::None => Ok(()),
|
||||
CommandRequirement::RequiresClientInit => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresAuth => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else if !status.legacy_auth {
|
||||
Err(RuntimeError::AuthRequired)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresUserSession => {
|
||||
// RequiresUserSession only checks session_activated, not legacy_auth.
|
||||
// This allows both:
|
||||
// - Online sessions: session_activated=true via activate_session()
|
||||
// - Offline sessions: session_activated=true via activate_offline_session()
|
||||
//
|
||||
// Commands needing Qobuz API should use RequiresAuth or RequiresCoreBridgeAuth.
|
||||
if !status.session_activated {
|
||||
Err(RuntimeError::UserSessionNotActivated)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresCoreBridgeAuth => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else if !status.legacy_auth {
|
||||
Err(RuntimeError::AuthRequired)
|
||||
} else if !status.session_activated {
|
||||
Err(RuntimeError::UserSessionNotActivated)
|
||||
} else if !status.corebridge_auth {
|
||||
Err(RuntimeError::CoreBridgeAuthMissing)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if in degraded state
|
||||
pub async fn is_degraded(&self) -> bool {
|
||||
matches!(self.state.read().await.state, RuntimeState::Degraded { .. })
|
||||
}
|
||||
|
||||
/// Set degraded state
|
||||
pub async fn set_degraded(&self, reason: DegradedReason) {
|
||||
self.set_state(RuntimeState::Degraded { reason }).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RuntimeManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Events emitted during runtime lifecycle
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub enum RuntimeEvent {
|
||||
/// Runtime initialized (client ready)
|
||||
RuntimeInitialized,
|
||||
/// Authentication state changed
|
||||
AuthChanged {
|
||||
logged_in: bool,
|
||||
user_id: Option<u64>,
|
||||
},
|
||||
/// Per-user session activated
|
||||
UserSessionActivated { user_id: u64 },
|
||||
/// Per-user session deactivated
|
||||
UserSessionDeactivated,
|
||||
/// CoreBridge auth failed
|
||||
CoreBridgeAuthFailed { error: String },
|
||||
/// Runtime entered degraded state
|
||||
RuntimeDegraded { reason: DegradedReason },
|
||||
/// Runtime fully ready
|
||||
RuntimeReady { user_id: u64 },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mixtape_context_set_clear() {
|
||||
let mgr = RuntimeManager::new();
|
||||
|
||||
// Initially None
|
||||
assert_eq!(mgr.get_queue_source_collection().await, None);
|
||||
|
||||
// Set Some
|
||||
mgr.set_queue_source_collection(Some("col-abc".to_string())).await;
|
||||
assert_eq!(
|
||||
mgr.get_queue_source_collection().await,
|
||||
Some("col-abc".to_string())
|
||||
);
|
||||
|
||||
// Clear to None
|
||||
mgr.set_queue_source_collection(None).await;
|
||||
assert_eq!(mgr.get_queue_source_collection().await, None);
|
||||
}
|
||||
}
|
||||
+5
-404
@@ -1,360 +1,10 @@
|
||||
//! Runtime Session Contract
|
||||
//!
|
||||
//! Implements the UI-agnostic runtime lifecycle as per ADR_RUNTIME_SESSION_CONTRACT.md
|
||||
//!
|
||||
//! Key concepts:
|
||||
//! - Single bootstrap entrypoint
|
||||
//! - Canonical state machine (Uninitialized -> Ready)
|
||||
//! - Typed errors (no string matching)
|
||||
//! - Command gating in backend
|
||||
//! - Lifecycle events
|
||||
//! Tauri runtime state wrapper.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use qbz_app::runtime::{
|
||||
CommandRequirement, DegradedReason, RuntimeError, RuntimeEvent, RuntimeManager, RuntimeState,
|
||||
RuntimeStatus,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Canonical runtime states
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", content = "data")]
|
||||
pub enum RuntimeState {
|
||||
/// Initial state - nothing initialized
|
||||
Uninitialized,
|
||||
/// Client initialized but no authentication
|
||||
InitializedNoAuth,
|
||||
/// Authenticated but per-user session not activated (transitional)
|
||||
AuthenticatedNoUserSession { user_id: u64 },
|
||||
/// Fully ready - all systems operational
|
||||
Ready { user_id: u64 },
|
||||
/// Degraded state - something is broken
|
||||
Degraded { reason: DegradedReason },
|
||||
}
|
||||
|
||||
impl Default for RuntimeState {
|
||||
fn default() -> Self {
|
||||
Self::Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
/// Reasons for degraded state
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "code", content = "message")]
|
||||
pub enum DegradedReason {
|
||||
/// Bundle token extraction failed
|
||||
BundleExtractionFailed(String),
|
||||
/// CoreBridge initialization failed
|
||||
CoreBridgeInitFailed(String),
|
||||
/// Network connectivity issues
|
||||
NetworkError(String),
|
||||
/// Database/storage issues
|
||||
StorageError(String),
|
||||
/// Session activation failed (per-user stores not initialized)
|
||||
SessionActivationFailed(String),
|
||||
}
|
||||
|
||||
/// Full runtime status returned by runtime_get_status and runtime_bootstrap
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeStatus {
|
||||
/// Current state
|
||||
pub state: RuntimeState,
|
||||
/// User ID if authenticated (None if not logged in)
|
||||
pub user_id: Option<u64>,
|
||||
/// Whether the API client is initialized (bundle tokens extracted)
|
||||
pub client_initialized: bool,
|
||||
/// Whether legacy auth is active
|
||||
pub legacy_auth: bool,
|
||||
/// Whether CoreBridge/V2 auth is active
|
||||
pub corebridge_auth: bool,
|
||||
/// Whether per-user session is activated
|
||||
pub session_activated: bool,
|
||||
/// Degraded reason if state is Degraded
|
||||
pub degraded_reason: Option<DegradedReason>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: RuntimeState::Uninitialized,
|
||||
user_id: None,
|
||||
client_initialized: false,
|
||||
legacy_auth: false,
|
||||
corebridge_auth: false,
|
||||
session_activated: false,
|
||||
degraded_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed runtime errors - no string matching in clients
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "code", content = "details")]
|
||||
pub enum RuntimeError {
|
||||
/// Runtime not initialized - call runtime_bootstrap first
|
||||
RuntimeNotInitialized,
|
||||
/// Authentication required for this operation
|
||||
AuthRequired,
|
||||
/// Per-user session not activated - call activate_user_session
|
||||
UserSessionNotActivated,
|
||||
/// CoreBridge/V2 auth missing - V2 commands won't work
|
||||
CoreBridgeAuthMissing,
|
||||
/// Runtime is in degraded state
|
||||
RuntimeDegraded(DegradedReason),
|
||||
/// Invalid user ID (e.g., 0)
|
||||
InvalidUserId,
|
||||
/// Bootstrap already in progress
|
||||
BootstrapInProgress,
|
||||
/// V2 CoreBridge authentication failed
|
||||
V2AuthFailed(String),
|
||||
/// V2 CoreBridge not initialized
|
||||
V2NotInitialized,
|
||||
/// Manual offline mode is on and the requested track is not available
|
||||
/// in any local cache. Emitted instead of silently streaming from the
|
||||
/// network (see issue #279).
|
||||
TrackNotAvailableOffline,
|
||||
/// Internal error
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::RuntimeNotInitialized => write!(f, "Runtime not initialized"),
|
||||
Self::AuthRequired => write!(f, "Authentication required"),
|
||||
Self::UserSessionNotActivated => write!(f, "User session not activated"),
|
||||
Self::CoreBridgeAuthMissing => write!(f, "CoreBridge authentication missing"),
|
||||
Self::RuntimeDegraded(reason) => write!(f, "Runtime degraded: {:?}", reason),
|
||||
Self::InvalidUserId => write!(f, "Invalid user ID"),
|
||||
Self::BootstrapInProgress => write!(f, "Bootstrap already in progress"),
|
||||
Self::V2AuthFailed(msg) => write!(f, "V2 authentication failed: {}", msg),
|
||||
Self::V2NotInitialized => write!(f, "V2 CoreBridge not initialized"),
|
||||
Self::TrackNotAvailableOffline => {
|
||||
write!(f, "Track not available in offline cache while in offline mode")
|
||||
}
|
||||
Self::Internal(msg) => write!(f, "Internal error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RuntimeError {}
|
||||
|
||||
/// Command prerequisites - what each command requires
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandRequirement {
|
||||
/// No requirements (public endpoints)
|
||||
None,
|
||||
/// Requires client to be initialized (bundle tokens)
|
||||
RequiresClientInit,
|
||||
/// Requires authentication (logged in)
|
||||
RequiresAuth,
|
||||
/// Requires per-user session to be activated
|
||||
RequiresUserSession,
|
||||
/// Requires CoreBridge/V2 auth (for V2 commands)
|
||||
RequiresCoreBridgeAuth,
|
||||
}
|
||||
|
||||
/// Runtime state manager - thread-safe, holds canonical state
|
||||
pub struct RuntimeManager {
|
||||
state: Arc<RwLock<RuntimeStatus>>,
|
||||
bootstrap_in_progress: Arc<RwLock<bool>>,
|
||||
/// Tracks which Mixtape/Collection the current queue was built from.
|
||||
/// Set by v2_enqueue_collection (replace mode), cleared by any
|
||||
/// non-Mixtape queue replacement (set_queue / clear_queue).
|
||||
/// Append-style ops (add/add_next/bulk) preserve this value.
|
||||
/// Persistence is in-memory only; session_queue_state table will
|
||||
/// add the source_collection_id column when it lands.
|
||||
queue_source_collection_id: RwLock<Option<String>>,
|
||||
}
|
||||
|
||||
impl RuntimeManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(RuntimeStatus::default())),
|
||||
bootstrap_in_progress: Arc::new(RwLock::new(false)),
|
||||
queue_source_collection_id: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set (or clear) which collection the current queue was built from.
|
||||
pub async fn set_queue_source_collection(&self, id: Option<String>) {
|
||||
*self.queue_source_collection_id.write().await = id;
|
||||
}
|
||||
|
||||
/// Return the collection ID that seeded the current queue, if any.
|
||||
pub async fn get_queue_source_collection(&self) -> Option<String> {
|
||||
self.queue_source_collection_id.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get current runtime status
|
||||
pub async fn get_status(&self) -> RuntimeStatus {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Update runtime state
|
||||
pub async fn set_state(&self, new_state: RuntimeState) {
|
||||
let mut status = self.state.write().await;
|
||||
status.state = new_state.clone();
|
||||
|
||||
// Update derived fields based on state
|
||||
match &new_state {
|
||||
RuntimeState::Uninitialized => {
|
||||
status.client_initialized = false;
|
||||
status.legacy_auth = false;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.user_id = None;
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::InitializedNoAuth => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = false;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.user_id = None;
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::AuthenticatedNoUserSession { user_id } => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = true;
|
||||
status.session_activated = false;
|
||||
status.user_id = Some(*user_id);
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::Ready { user_id } => {
|
||||
status.client_initialized = true;
|
||||
status.legacy_auth = true;
|
||||
status.corebridge_auth = true;
|
||||
status.session_activated = true;
|
||||
status.user_id = Some(*user_id);
|
||||
status.degraded_reason = None;
|
||||
}
|
||||
RuntimeState::Degraded { reason } => {
|
||||
status.degraded_reason = Some(reason.clone());
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[Runtime] State changed to: {:?}", new_state);
|
||||
}
|
||||
|
||||
/// Mark client as initialized
|
||||
pub async fn set_client_initialized(&self, initialized: bool) {
|
||||
let mut status = self.state.write().await;
|
||||
status.client_initialized = initialized;
|
||||
if initialized && status.state == RuntimeState::Uninitialized {
|
||||
status.state = RuntimeState::InitializedNoAuth;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark legacy auth status
|
||||
pub async fn set_legacy_auth(&self, auth: bool, user_id: Option<u64>) {
|
||||
let mut status = self.state.write().await;
|
||||
status.legacy_auth = auth;
|
||||
if auth {
|
||||
if let Some(uid) = user_id {
|
||||
status.user_id = Some(uid);
|
||||
if !status.session_activated {
|
||||
status.state = RuntimeState::AuthenticatedNoUserSession { user_id: uid };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status.user_id = None;
|
||||
status.corebridge_auth = false;
|
||||
status.session_activated = false;
|
||||
status.state = RuntimeState::InitializedNoAuth;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark CoreBridge auth status
|
||||
pub async fn set_corebridge_auth(&self, auth: bool) {
|
||||
let mut status = self.state.write().await;
|
||||
status.corebridge_auth = auth;
|
||||
}
|
||||
|
||||
/// Mark session as activated
|
||||
pub async fn set_session_activated(&self, activated: bool, user_id: u64) {
|
||||
let mut status = self.state.write().await;
|
||||
status.session_activated = activated;
|
||||
if activated && status.legacy_auth && status.corebridge_auth {
|
||||
status.state = RuntimeState::Ready { user_id };
|
||||
status.user_id = Some(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if bootstrap is in progress
|
||||
pub async fn is_bootstrap_in_progress(&self) -> bool {
|
||||
*self.bootstrap_in_progress.read().await
|
||||
}
|
||||
|
||||
/// Set bootstrap in progress flag
|
||||
pub async fn set_bootstrap_in_progress(&self, in_progress: bool) {
|
||||
*self.bootstrap_in_progress.write().await = in_progress;
|
||||
}
|
||||
|
||||
/// Validate command requirements against current state
|
||||
pub async fn check_requirements(&self, req: CommandRequirement) -> Result<(), RuntimeError> {
|
||||
let status = self.state.read().await;
|
||||
|
||||
match req {
|
||||
CommandRequirement::None => Ok(()),
|
||||
CommandRequirement::RequiresClientInit => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresAuth => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else if !status.legacy_auth {
|
||||
Err(RuntimeError::AuthRequired)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresUserSession => {
|
||||
// RequiresUserSession only checks session_activated, not legacy_auth.
|
||||
// This allows both:
|
||||
// - Online sessions: session_activated=true via activate_session()
|
||||
// - Offline sessions: session_activated=true via activate_offline_session()
|
||||
//
|
||||
// Commands needing Qobuz API should use RequiresAuth or RequiresCoreBridgeAuth.
|
||||
if !status.session_activated {
|
||||
Err(RuntimeError::UserSessionNotActivated)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
CommandRequirement::RequiresCoreBridgeAuth => {
|
||||
if !status.client_initialized {
|
||||
Err(RuntimeError::RuntimeNotInitialized)
|
||||
} else if !status.legacy_auth {
|
||||
Err(RuntimeError::AuthRequired)
|
||||
} else if !status.session_activated {
|
||||
Err(RuntimeError::UserSessionNotActivated)
|
||||
} else if !status.corebridge_auth {
|
||||
Err(RuntimeError::CoreBridgeAuthMissing)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if in degraded state
|
||||
pub async fn is_degraded(&self) -> bool {
|
||||
matches!(self.state.read().await.state, RuntimeState::Degraded { .. })
|
||||
}
|
||||
|
||||
/// Set degraded state
|
||||
pub async fn set_degraded(&self, reason: DegradedReason) {
|
||||
self.set_state(RuntimeState::Degraded { reason }).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RuntimeManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri state wrapper
|
||||
pub struct RuntimeManagerState(pub Arc<RuntimeManager>);
|
||||
@@ -374,52 +24,3 @@ impl Default for RuntimeManagerState {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Lifecycle Events ====================
|
||||
|
||||
/// Events emitted during runtime lifecycle
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub enum RuntimeEvent {
|
||||
/// Runtime initialized (client ready)
|
||||
RuntimeInitialized,
|
||||
/// Authentication state changed
|
||||
AuthChanged {
|
||||
logged_in: bool,
|
||||
user_id: Option<u64>,
|
||||
},
|
||||
/// Per-user session activated
|
||||
UserSessionActivated { user_id: u64 },
|
||||
/// Per-user session deactivated
|
||||
UserSessionDeactivated,
|
||||
/// CoreBridge auth failed
|
||||
CoreBridgeAuthFailed { error: String },
|
||||
/// Runtime entered degraded state
|
||||
RuntimeDegraded { reason: DegradedReason },
|
||||
/// Runtime fully ready
|
||||
RuntimeReady { user_id: u64 },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mixtape_context_set_clear() {
|
||||
let mgr = RuntimeManager::new();
|
||||
|
||||
// Initially None
|
||||
assert_eq!(mgr.get_queue_source_collection().await, None);
|
||||
|
||||
// Set Some
|
||||
mgr.set_queue_source_collection(Some("col-abc".to_string())).await;
|
||||
assert_eq!(
|
||||
mgr.get_queue_source_collection().await,
|
||||
Some("col-abc".to_string())
|
||||
);
|
||||
|
||||
// Clear to None
|
||||
mgr.set_queue_source_collection(None).await;
|
||||
assert_eq!(mgr.get_queue_source_collection().await, None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user