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
This commit is contained in:
vicrodh committed 2026-06-02 12:47:10 -06:00
1 parent b6ddfc1b5f
commit 979a09e7b2
3 files changed
+146 -100

No files matched your search

+6 -4
View File
@@ -15,10 +15,12 @@ pub use startup::{compute_effective_startup, QconnectStartupMode};
pub use error::QconnectAppError; pub use error::QconnectAppError;
pub use events::{NoOpEventSink, QconnectAppEvent, QconnectEventSink}; pub use events::{NoOpEventSink, QconnectAppEvent, QconnectEventSink};
pub use session::{ pub use session::{
compute_connection_state, deferred_join_reason, quality_from_max_audio_quality, compute_connection_state, deferred_join_reason, find_unique_renderer_id,
should_arm_renderer_watchdog, should_reask_queue_state, ConnectionDecision, is_local_renderer_active, is_peer_renderer_active, normalize_active_renderer_id,
QconnectLifecycleState, QconnectRendererInfo, QconnectSessionRendererState, quality_from_max_audio_quality, refresh_local_renderer_id, renderer_allows_remote_volume,
QconnectSessionState, RendererStatus, ServerActiveState, 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, JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION,
QCONNECT_RENDERER_LOST_TIMEOUT_MS, QCONNECT_RENDERER_LOST_TIMEOUT_MS,
}; };
+117
View File
@@ -237,6 +237,123 @@ pub struct QconnectRendererInfo {
pub volume_remote_control: Option<i32>, pub volume_remote_control: Option<i32>,
} }
/// 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<String>,
pub brand: Option<String>,
pub model: Option<String>,
pub device_type: Option<i32>,
}
/// 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<i64>) -> Option<i32> {
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<i32> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+23 -96
View File
@@ -13,7 +13,7 @@ use super::queue_resolution::{
find_cursor_index_by_queue_item_id, normalize_current_queue_item_id_from_queue_state, find_cursor_index_by_queue_item_id, normalize_current_queue_item_id_from_queue_state,
ordered_queue_cursors, QconnectOrderedQueueCursor, ordered_queue_cursors, QconnectOrderedQueueCursor,
}; };
use super::transport::{default_qconnect_device_info, resolve_qconnect_device_uuid}; use super::transport::default_qconnect_device_info;
use super::{ use super::{
QconnectQueueVersionPayload, QconnectRemoteSyncState, QconnectVisibleQueueProjection, QconnectQueueVersionPayload, QconnectRemoteSyncState, QconnectVisibleQueueProjection,
}; };
@@ -31,6 +31,14 @@ pub(super) use qconnect_app::{compute_connection_state, ServerActiveState};
/// command surface keeps the same serialized shape. /// command surface keeps the same serialized shape.
pub use qconnect_app::{QconnectRendererInfo, QconnectSessionRendererState, QconnectSessionState}; 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( pub(super) fn queue_item_snapshot_for_cursor(
queue: &QConnectQueueState, queue: &QConnectQueueState,
cursor: QconnectOrderedQueueCursor, 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)] #[derive(Debug, Clone, Serialize)]
pub(crate) struct QconnectRendererReportDebugEvent { pub(crate) struct QconnectRendererReportDebugEvent {
pub(super) requested_current_queue_item_id: Option<i32>, pub(super) requested_current_queue_item_id: Option<i32>,
@@ -71,76 +71,21 @@ pub(crate) struct QconnectRendererReportDebugEvent {
pub(super) resolution_strategy: String, pub(super) resolution_strategy: String,
} }
/// Whether the active renderer permits remote volume control. Absent capability /// Thin Tauri wrapper: resolves this device's identity (uuid + device-info)
/// (None) defaults to allowed; only an explicit non-ALLOWED value disables. /// adapter-side, then delegates to the frontend-agnostic resolver in
pub(super) fn renderer_allows_remote_volume(info: &QconnectRendererInfo) -> bool { /// qconnect-app. qconnect-app stays identity-free. The eager device-info build
matches!(info.volume_remote_control, None | Some(1)) /// 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) { pub(super) fn refresh_local_renderer_id(session: &mut QconnectSessionState) {
let local_device_uuid = resolve_qconnect_device_uuid(); let info = default_qconnect_device_info();
if let Some(renderer_id) = session let identity = qconnect_app::LocalIdentity {
.renderers device_uuid: info.device_uuid.unwrap_or_default(),
.iter() friendly_name: info.friendly_name,
.find(|renderer| renderer.device_uuid.as_deref() == Some(local_device_uuid.as_str())) brand: info.brand,
.map(|renderer| renderer.renderer_id) model: info.model,
{ device_type: info.device_type,
session.local_renderer_id = Some(renderer_id); };
return; qconnect_app::refresh_local_renderer_id(session, &identity);
}
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<i64>) -> Option<i32> {
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,
}
} }
pub(super) fn sync_session_renderer_active_flags(state: &mut QconnectRemoteSyncState) { 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<i32> {
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( pub(super) fn build_session_renderer_snapshot(
queue: &QConnectQueueState, queue: &QConnectQueueState,
renderer_state: Option<&QconnectSessionRendererState>, renderer_state: Option<&QconnectSessionRendererState>,