From 979a09e7b2fc9962e2dc42ddbd3dbb5165f3f06c Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:47:10 -0600 Subject: [PATCH] refactor(qconnect): move session mutators to qconnect-app with injected identity (slice 2+4) Relocate the pure session mutators (refresh_local_renderer_id, is_peer/local_renderer_active, find_unique_renderer_id, normalize_active_renderer_id, renderer_allows_remote_volume) and the QconnectFileAudioQualitySnapshot type into qconnect-app::session. refresh_local_renderer_id now takes an injected LocalIdentity (device uuid + device-info) so qconnect-app stays free of the persistence / transport crates (ADR-006). A thin Tauri wrapper resolves the identity from default_qconnect_device_info() and delegates, keeping the three event_sink call sites and the existing tests unchanged. The eager device-info build is idempotent and side-effect-free, so renderer-id resolution is byte-identical. qconnect-app + src-tauri qconnect tests green (33 / 42). cc --- crates/qconnect-app/src/lib.rs | 10 ++- crates/qconnect-app/src/session.rs | 117 ++++++++++++++++++++++++++++ src-tauri/src/qconnect/session.rs | 119 ++++++----------------------- 3 files changed, 146 insertions(+), 100 deletions(-) diff --git a/crates/qconnect-app/src/lib.rs b/crates/qconnect-app/src/lib.rs index 9715e0804..7f7e0fad1 100644 --- a/crates/qconnect-app/src/lib.rs +++ b/crates/qconnect-app/src/lib.rs @@ -15,10 +15,12 @@ pub use startup::{compute_effective_startup, QconnectStartupMode}; pub use error::QconnectAppError; pub use events::{NoOpEventSink, QconnectAppEvent, QconnectEventSink}; pub use session::{ - compute_connection_state, deferred_join_reason, quality_from_max_audio_quality, - should_arm_renderer_watchdog, should_reask_queue_state, ConnectionDecision, - QconnectLifecycleState, QconnectRendererInfo, QconnectSessionRendererState, - QconnectSessionState, RendererStatus, ServerActiveState, + compute_connection_state, deferred_join_reason, find_unique_renderer_id, + is_local_renderer_active, is_peer_renderer_active, normalize_active_renderer_id, + quality_from_max_audio_quality, refresh_local_renderer_id, renderer_allows_remote_volume, + should_arm_renderer_watchdog, should_reask_queue_state, ConnectionDecision, LocalIdentity, + QconnectFileAudioQualitySnapshot, QconnectLifecycleState, QconnectRendererInfo, + QconnectSessionRendererState, QconnectSessionState, RendererStatus, ServerActiveState, JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION, QCONNECT_RENDERER_LOST_TIMEOUT_MS, }; diff --git a/crates/qconnect-app/src/session.rs b/crates/qconnect-app/src/session.rs index 7e69f780f..a5e219115 100644 --- a/crates/qconnect-app/src/session.rs +++ b/crates/qconnect-app/src/session.rs @@ -237,6 +237,123 @@ pub struct QconnectRendererInfo { pub volume_remote_control: Option, } +/// Resolved local-device identity injected by the frontend adapter into the +/// session layer. Lets `refresh_local_renderer_id` stay frontend-agnostic: the +/// Tauri adapter resolves it from its persisted device uuid + device-info +/// builder, the Slint adapter from the shared qbz-app identity — qconnect-app +/// never depends on the persistence/transport crates. +#[derive(Debug, Clone, Default)] +pub struct LocalIdentity { + pub device_uuid: String, + pub friendly_name: Option, + pub brand: Option, + pub model: Option, + pub device_type: Option, +} + +/// Snapshot of the last reported file (stream) audio quality, used to dedup +/// outbound RndrSrvrFileAudioQualityChanged reports. Moved here with the +/// session topology so the relocated sync accumulator is self-contained. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QconnectFileAudioQualitySnapshot { + pub sampling_rate: i32, + pub bit_depth: i32, + pub nb_channels: i32, + pub audio_quality: i32, +} + +/// Whether the active renderer permits remote volume control. Absent capability +/// (None) defaults to allowed; only an explicit non-ALLOWED value disables. +pub fn renderer_allows_remote_volume(info: &QconnectRendererInfo) -> bool { + matches!(info.volume_remote_control, None | Some(1)) +} + +/// Resolve the local renderer id within the session by matching the injected +/// local identity: first by exact device_uuid, then by a unique device +/// fingerprint (name+brand+model+type), then a looser name+type fingerprint. +/// Identical resolution order to the prior Tauri-side fn; identity is now data. +pub fn refresh_local_renderer_id(session: &mut QconnectSessionState, identity: &LocalIdentity) { + if let Some(renderer_id) = session + .renderers + .iter() + .find(|renderer| renderer.device_uuid.as_deref() == Some(identity.device_uuid.as_str())) + .map(|renderer| renderer.renderer_id) + { + session.local_renderer_id = Some(renderer_id); + return; + } + + let local_friendly_name = identity.friendly_name.as_deref(); + let local_brand = identity.brand.as_deref(); + let local_model = identity.model.as_deref(); + let local_device_type = identity.device_type; + + // Some server ADD_RENDERER payloads omit device_uuid for the local renderer. + // Fall back to a unique device fingerprint so controller-side handoff logic + // can still distinguish local vs peer renderers. + if let Some(renderer_id) = find_unique_renderer_id(session, |renderer| { + renderer.friendly_name.as_deref() == local_friendly_name + && renderer.brand.as_deref() == local_brand + && renderer.model.as_deref() == local_model + && renderer.device_type == local_device_type + }) { + session.local_renderer_id = Some(renderer_id); + return; + } + + if let Some(renderer_id) = find_unique_renderer_id(session, |renderer| { + renderer.friendly_name.as_deref() == local_friendly_name + && renderer.device_type == local_device_type + }) { + session.local_renderer_id = Some(renderer_id); + return; + } + + session.local_renderer_id = None; +} + +pub fn normalize_active_renderer_id(value: Option) -> Option { + value + .filter(|renderer_id| *renderer_id >= 0) + .and_then(|renderer_id| i32::try_from(renderer_id).ok()) +} + +pub fn is_peer_renderer_active(session: &QconnectSessionState) -> bool { + match (session.active_renderer_id, session.local_renderer_id) { + (Some(active_renderer_id), Some(local_renderer_id)) => { + active_renderer_id != local_renderer_id + } + _ => false, + } +} + +pub fn is_local_renderer_active(session: &QconnectSessionState) -> bool { + match (session.active_renderer_id, session.local_renderer_id) { + (Some(active_renderer_id), Some(local_renderer_id)) => { + active_renderer_id == local_renderer_id + } + _ => false, + } +} + +pub fn find_unique_renderer_id( + session: &QconnectSessionState, + predicate: impl Fn(&QconnectRendererInfo) -> bool, +) -> Option { + let mut matches = session + .renderers + .iter() + .filter(|renderer| predicate(renderer)) + .map(|renderer| renderer.renderer_id); + + let first = matches.next()?; + if matches.next().is_some() { + return None; + } + + Some(first) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/qconnect/session.rs b/src-tauri/src/qconnect/session.rs index a59f36b38..6f39756a4 100644 --- a/src-tauri/src/qconnect/session.rs +++ b/src-tauri/src/qconnect/session.rs @@ -13,7 +13,7 @@ use super::queue_resolution::{ find_cursor_index_by_queue_item_id, normalize_current_queue_item_id_from_queue_state, ordered_queue_cursors, QconnectOrderedQueueCursor, }; -use super::transport::{default_qconnect_device_info, resolve_qconnect_device_uuid}; +use super::transport::default_qconnect_device_info; use super::{ QconnectQueueVersionPayload, QconnectRemoteSyncState, QconnectVisibleQueueProjection, }; @@ -31,6 +31,14 @@ pub(super) use qconnect_app::{compute_connection_state, ServerActiveState}; /// command surface keeps the same serialized shape. pub use qconnect_app::{QconnectRendererInfo, QconnectSessionRendererState, QconnectSessionState}; +/// Pure session mutators + the file-audio-quality snapshot type also moved to +/// qconnect-app (slice 2+4). Re-exported so existing `super::session::…` / +/// `super::…` references compile unchanged. +pub(super) use qconnect_app::{ + find_unique_renderer_id, is_local_renderer_active, is_peer_renderer_active, + normalize_active_renderer_id, renderer_allows_remote_volume, QconnectFileAudioQualitySnapshot, +}; + pub(super) fn queue_item_snapshot_for_cursor( queue: &QConnectQueueState, cursor: QconnectOrderedQueueCursor, @@ -46,14 +54,6 @@ pub(super) fn queue_item_snapshot_for_cursor( } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct QconnectFileAudioQualitySnapshot { - pub(super) sampling_rate: i32, - pub(super) bit_depth: i32, - pub(super) nb_channels: i32, - pub(super) audio_quality: i32, -} - #[derive(Debug, Clone, Serialize)] pub(crate) struct QconnectRendererReportDebugEvent { pub(super) requested_current_queue_item_id: Option, @@ -71,76 +71,21 @@ pub(crate) struct QconnectRendererReportDebugEvent { pub(super) resolution_strategy: String, } -/// Whether the active renderer permits remote volume control. Absent capability -/// (None) defaults to allowed; only an explicit non-ALLOWED value disables. -pub(super) fn renderer_allows_remote_volume(info: &QconnectRendererInfo) -> bool { - matches!(info.volume_remote_control, None | Some(1)) -} - +/// Thin Tauri wrapper: resolves this device's identity (uuid + device-info) +/// adapter-side, then delegates to the frontend-agnostic resolver in +/// qconnect-app. qconnect-app stays identity-free. The eager device-info build +/// is idempotent and side-effect-free (env reads + the cached uuid), so the +/// resolved renderer id is identical to the prior in-place implementation. pub(super) fn refresh_local_renderer_id(session: &mut QconnectSessionState) { - let local_device_uuid = resolve_qconnect_device_uuid(); - if let Some(renderer_id) = session - .renderers - .iter() - .find(|renderer| renderer.device_uuid.as_deref() == Some(local_device_uuid.as_str())) - .map(|renderer| renderer.renderer_id) - { - session.local_renderer_id = Some(renderer_id); - return; - } - - let local_device_info = default_qconnect_device_info(); - let local_friendly_name = local_device_info.friendly_name.as_deref(); - let local_brand = local_device_info.brand.as_deref(); - let local_model = local_device_info.model.as_deref(); - let local_device_type = local_device_info.device_type; - - // Some server ADD_RENDERER payloads omit device_uuid for the local renderer. - // Fall back to a unique device fingerprint so controller-side handoff logic - // can still distinguish local vs peer renderers. - if let Some(renderer_id) = find_unique_renderer_id(session, |renderer| { - renderer.friendly_name.as_deref() == local_friendly_name - && renderer.brand.as_deref() == local_brand - && renderer.model.as_deref() == local_model - && renderer.device_type == local_device_type - }) { - session.local_renderer_id = Some(renderer_id); - return; - } - - if let Some(renderer_id) = find_unique_renderer_id(session, |renderer| { - renderer.friendly_name.as_deref() == local_friendly_name - && renderer.device_type == local_device_type - }) { - session.local_renderer_id = Some(renderer_id); - return; - } - - session.local_renderer_id = None; -} - -pub(super) fn normalize_active_renderer_id(value: Option) -> Option { - value - .filter(|renderer_id| *renderer_id >= 0) - .and_then(|renderer_id| i32::try_from(renderer_id).ok()) -} - -pub(super) fn is_peer_renderer_active(session: &QconnectSessionState) -> bool { - match (session.active_renderer_id, session.local_renderer_id) { - (Some(active_renderer_id), Some(local_renderer_id)) => { - active_renderer_id != local_renderer_id - } - _ => false, - } -} - -pub(super) fn is_local_renderer_active(session: &QconnectSessionState) -> bool { - match (session.active_renderer_id, session.local_renderer_id) { - (Some(active_renderer_id), Some(local_renderer_id)) => { - active_renderer_id == local_renderer_id - } - _ => false, - } + let info = default_qconnect_device_info(); + let identity = qconnect_app::LocalIdentity { + device_uuid: info.device_uuid.unwrap_or_default(), + friendly_name: info.friendly_name, + brand: info.brand, + model: info.model, + device_type: info.device_type, + }; + qconnect_app::refresh_local_renderer_id(session, &identity); } pub(super) fn sync_session_renderer_active_flags(state: &mut QconnectRemoteSyncState) { @@ -169,24 +114,6 @@ pub(super) fn ensure_session_renderer_state( }) } -pub(super) fn find_unique_renderer_id( - session: &QconnectSessionState, - predicate: impl Fn(&QconnectRendererInfo) -> bool, -) -> Option { - let mut matches = session - .renderers - .iter() - .filter(|renderer| predicate(renderer)) - .map(|renderer| renderer.renderer_id); - - let first = matches.next()?; - if matches.next().is_some() { - return None; - } - - Some(first) -} - pub(super) fn build_session_renderer_snapshot( queue: &QConnectQueueState, renderer_state: Option<&QconnectSessionRendererState>,