refactor(qbz-app): add AppRuntime shell facade skeleton
Adds a framework-agnostic composition root for non-Tauri UI shells (Slint, TUI, headless) as Task 1 of the Slint POC readiness audit (session-20-slint-poc-readiness-audit). AppRuntime<A> owns an Arc<QbzCore<A>> plus the runtime state machine, built with the same recipe as the Tauri CoreBridge but with no Tauri dependency. Scope is composition and accessors only; per-user store ownership and session activation are deferred to Task 2. qbz-app now depends on qbz-core, qbz-player, qbz-audio, and qbz-models. The Tauri app does not consume this module; it is purely additive. cc
This commit is contained in:
5 files changed
+151
No files matched your search
Generated
+4
@@ -2912,6 +2912,10 @@ dependencies = [
|
||||
"dirs",
|
||||
"log",
|
||||
"md-5",
|
||||
"qbz-audio",
|
||||
"qbz-core",
|
||||
"qbz-models",
|
||||
"qbz-player",
|
||||
"rand 0.10.1",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
|
||||
@@ -6,6 +6,10 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
qbz-audio = { path = "../qbz-audio" }
|
||||
qbz-player = { path = "../qbz-player" }
|
||||
qbz-core = { path = "../qbz-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -4,5 +4,6 @@ pub mod graphics_detection;
|
||||
pub mod playback_context;
|
||||
pub mod runtime;
|
||||
pub mod session_store;
|
||||
pub mod shell;
|
||||
pub mod settings;
|
||||
pub mod user_data;
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Framework-agnostic application runtime facade.
|
||||
//!
|
||||
//! [`AppRuntime`] is the composition root that a non-Tauri UI shell (Slint,
|
||||
//! TUI, headless) builds on. It owns an `Arc<QbzCore<A>>` plus the
|
||||
//! framework-agnostic runtime state machine, constructed the same way the
|
||||
//! Tauri `CoreBridge` does but without any Tauri dependency.
|
||||
//!
|
||||
//! Scope of this module (Session 21 / Task 1 of the Slint POC readiness
|
||||
//! 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
|
||||
//! construction path. `AppRuntime` is purely additive.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_audio::{settings::AudioSettingsStore, AudioDiagnostic, AudioSettings, VisualizerTap};
|
||||
use qbz_core::{FrontendAdapter, QbzCore};
|
||||
use qbz_player::Player;
|
||||
|
||||
use crate::runtime::RuntimeManager;
|
||||
|
||||
/// Composition root for a non-Tauri UI shell.
|
||||
///
|
||||
/// Generic over the [`FrontendAdapter`] the shell supplies, so the same
|
||||
/// facade serves a Slint adapter, a TUI adapter, or a headless one.
|
||||
pub struct AppRuntime<A: FrontendAdapter + Send + Sync + 'static> {
|
||||
core: Arc<QbzCore<A>>,
|
||||
runtime: Arc<RuntimeManager>,
|
||||
}
|
||||
|
||||
impl<A: FrontendAdapter + Send + Sync + 'static> AppRuntime<A> {
|
||||
/// Build with explicit audio settings.
|
||||
///
|
||||
/// Performs no disk or network access — used by tests and by shells that
|
||||
/// already have audio settings loaded.
|
||||
pub fn with_audio_settings(
|
||||
adapter: A,
|
||||
device_name: Option<String>,
|
||||
audio_settings: AudioSettings,
|
||||
visualizer_tap: Option<VisualizerTap>,
|
||||
) -> Self {
|
||||
let diagnostic = AudioDiagnostic::new();
|
||||
let player = Player::new(device_name, audio_settings, visualizer_tap, diagnostic);
|
||||
let core = QbzCore::new(adapter, player);
|
||||
Self {
|
||||
core: Arc::new(core),
|
||||
runtime: Arc::new(RuntimeManager::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build, loading persisted audio settings from [`AudioSettingsStore`].
|
||||
///
|
||||
/// Falls back to defaults when no settings are saved or the store cannot
|
||||
/// be opened. This mirrors the recipe in the Tauri `CoreBridge::new`.
|
||||
/// It does not touch the network — call [`AppRuntime::init`] for that.
|
||||
pub fn new(adapter: A) -> Self {
|
||||
let (device_name, audio_settings) = AudioSettingsStore::new()
|
||||
.ok()
|
||||
.and_then(|store| {
|
||||
store
|
||||
.get_settings()
|
||||
.ok()
|
||||
.map(|settings| (settings.output_device.clone(), settings))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log::info!("[AppRuntime] No saved audio settings, using defaults");
|
||||
(None, AudioSettings::default())
|
||||
});
|
||||
Self::with_audio_settings(adapter, device_name, audio_settings, None)
|
||||
}
|
||||
|
||||
/// Initialize the core (extracts Qobuz bundle tokens).
|
||||
///
|
||||
/// Best-effort and offline-tolerant: a network failure here leaves the
|
||||
/// core usable for local/offline playback, matching [`QbzCore::init`].
|
||||
pub async fn init(&self) -> Result<(), String> {
|
||||
self.core.init().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The orchestrator. Shells reach catalog, playback, queue, and auth
|
||||
/// functionality through this handle.
|
||||
pub fn core(&self) -> &Arc<QbzCore<A>> {
|
||||
&self.core
|
||||
}
|
||||
|
||||
/// The framework-agnostic runtime state machine.
|
||||
pub fn runtime(&self) -> &Arc<RuntimeManager> {
|
||||
&self.runtime
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::runtime::RuntimeState;
|
||||
use qbz_core::NoOpAdapter;
|
||||
|
||||
#[test]
|
||||
fn builds_with_explicit_audio_settings() {
|
||||
let rt = AppRuntime::with_audio_settings(
|
||||
NoOpAdapter,
|
||||
None,
|
||||
AudioSettings::default(),
|
||||
None,
|
||||
);
|
||||
// The core handle is reachable without panicking.
|
||||
let _core = rt.core();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_state_machine_starts_uninitialized() {
|
||||
let rt = AppRuntime::with_audio_settings(
|
||||
NoOpAdapter,
|
||||
None,
|
||||
AudioSettings::default(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
rt.runtime().get_status().await.state,
|
||||
RuntimeState::Uninitialized
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_reports_no_session_before_login() {
|
||||
let rt = AppRuntime::with_audio_settings(
|
||||
NoOpAdapter,
|
||||
None,
|
||||
AudioSettings::default(),
|
||||
None,
|
||||
);
|
||||
assert!(!rt.core().has_session().await);
|
||||
assert!(!rt.core().is_api_initialized().await);
|
||||
}
|
||||
}
|
||||
Generated
+4
@@ -5032,6 +5032,10 @@ dependencies = [
|
||||
"dirs",
|
||||
"log",
|
||||
"md-5",
|
||||
"qbz-audio",
|
||||
"qbz-core",
|
||||
"qbz-models",
|
||||
"qbz-player",
|
||||
"rand 0.10.1",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
|
||||
Reference in new issue
Block a user