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:
vicrodh committed 2026-05-18 07:28:19 -06:00
1 parent d4ea8b01a7
commit a213b37807
5 files changed
+151

No files matched your search

+4
View File
@@ -2912,6 +2912,10 @@ dependencies = [
"dirs",
"log",
"md-5",
"qbz-audio",
"qbz-core",
"qbz-models",
"qbz-player",
"rand 0.10.1",
"rusqlite",
"serde",
+4
View File
@@ -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 }
+1
View File
@@ -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;
+138
View File
@@ -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);
}
}
+4
View File
@@ -5032,6 +5032,10 @@ dependencies = [
"dirs",
"log",
"md-5",
"qbz-audio",
"qbz-core",
"qbz-models",
"qbz-player",
"rand 0.10.1",
"rusqlite",
"serde",