Files
qbz/crates/qbz-qt/src/qconnect_lan_qt.rs
T

551 lines
19 KiB
Rust

//! Thin Qt adapter for the official QConnect LAN receiver surface.
//!
//! This module projects Qt's existing QConnect identity into the LAN wire,
//! owns the listener/mDNS lifetime, and drains the bounded latest-wins
//! admission slot on one dedicated thread. Cloud validation and transactional
//! authority switching deliberately remain outside this adapter.
use std::error::Error;
use std::fmt;
use std::future::Future;
use std::sync::{Arc, Mutex as StdMutex};
use std::thread::JoinHandle;
use std::time::Duration;
use qbz_models::Quality;
use qconnect_app::{AuthorityCell, AuthorityOrigin, AuthorityStamp};
use qconnect_lan::{
ConnectInfo, DeviceType, DisplayInfo, EndpointPolicy, HandoffCandidate, LanError,
LanProjection, LanService, LanServiceConfig, MaxAudioQuality,
};
use crate::qconnect_transport_qt::default_qconnect_device_info;
const ADMISSION_WAIT: Duration = Duration::from_secs(60);
const QT_MODEL: &str = "QBZ Desktop";
const ADMISSION_THREAD_NAME: &str = "qbz-qt-qconnect-lan-admission";
/// Stamp-aware bridge between runtime authority and the synchronous LAN
/// projection read by the HTTP worker.
///
/// Every Qt authority install/clear is serialized through this short-held
/// mutex. An old runtime event can therefore never overwrite the session of a
/// replacement runtime, and a prepared candidate is not exposed until the
/// same critical section that installs its authority.
#[derive(Clone, Default)]
pub(crate) struct QtLanProjectionSlot {
inner: Arc<StdMutex<ProjectionSlotState>>,
}
#[derive(Default)]
struct ProjectionSlotState {
projection: Option<LanProjection>,
installed: Option<InstalledSessionProjection>,
}
struct InstalledSessionProjection {
stamp: AuthorityStamp,
session_id: Option<String>,
}
impl QtLanProjectionSlot {
fn lock(&self) -> std::sync::MutexGuard<'_, ProjectionSlotState> {
self.inner.lock().unwrap_or_else(|poisoned| {
log::error!("[QConnect LAN] projection slot recovered from a poisoned lock");
let guard = poisoned.into_inner();
self.inner.clear_poison();
guard
})
}
fn write_projection(state: &ProjectionSlotState, session_id: Option<String>) {
if let Some(projection) = state.projection.as_ref() {
projection.set_current_session_id(session_id);
}
}
/// Install a runtime and its already-confirmed public session atomically
/// with respect to every projection update. `session_id` is `None` for a
/// fresh owner until its first cloud SESSION_STATE arrives.
pub(crate) fn install_authority(
&self,
authority: &AuthorityCell,
stamp: AuthorityStamp,
session_id: Option<&str>,
) -> bool {
let mut state = self.lock();
// Fail closed during the tiny install boundary: the old session may no
// longer be current, while exposing the candidate before install would
// violate the handoff transaction.
Self::write_projection(&state, None);
if !authority.install(stamp) {
let current = authority.current();
let restore = state
.installed
.as_ref()
.filter(|installed| Some(installed.stamp) == current)
.and_then(|installed| installed.session_id.clone());
if current != state.installed.as_ref().map(|installed| installed.stamp) {
state.installed = None;
}
Self::write_projection(&state, restore);
return false;
}
let session_id = session_id.map(str::to_string);
state.installed = Some(InstalledSessionProjection {
stamp,
session_id: session_id.clone(),
});
Self::write_projection(&state, session_id);
true
}
/// Publish a cloud-confirmed owner session from an event belonging to the
/// exact installed runtime. Delegated session ids enter only through
/// `install_authority` after their transactional commit.
pub(crate) fn confirm_owner_session(
&self,
authority: &AuthorityCell,
stamp: AuthorityStamp,
session_id: &str,
) -> bool {
if stamp.origin() != AuthorityOrigin::Owner || session_id.is_empty() {
return false;
}
let mut state = self.lock();
if !authority.is_current(stamp)
|| state.installed.as_ref().map(|installed| installed.stamp) != Some(stamp)
{
return false;
}
let session_id = session_id.to_string();
if let Some(installed) = state.installed.as_mut() {
installed.session_id = Some(session_id.clone());
}
Self::write_projection(&state, Some(session_id));
true
}
/// Snapshot the exact installed session for listener construction. This
/// seeds GET connect-info before the HTTP worker can accept its first call.
pub(crate) fn current_session_id(
&self,
authority: &AuthorityCell,
expected: AuthorityStamp,
) -> Option<String> {
let state = self.lock();
if !authority.is_current(expected) {
return None;
}
state
.installed
.as_ref()
.filter(|installed| installed.stamp == expected)
.and_then(|installed| installed.session_id.clone())
}
pub(crate) fn attach(&self, authority: &AuthorityCell, projection: LanProjection) {
let mut state = self.lock();
let current = authority.current();
let session_id = state
.installed
.as_ref()
.filter(|installed| Some(installed.stamp) == current)
.and_then(|installed| installed.session_id.clone());
projection.set_current_session_id(session_id);
state.projection = Some(projection);
}
pub(crate) fn detach(&self) {
self.lock().projection.take();
}
pub(crate) fn clear_authority(&self, authority: &AuthorityCell) -> Option<AuthorityStamp> {
let mut state = self.lock();
Self::write_projection(&state, None);
let previous = authority.clear();
state.installed = None;
previous
}
pub(crate) fn clear_if_current(
&self,
authority: &AuthorityCell,
expected: AuthorityStamp,
) -> bool {
let mut state = self.lock();
if !authority.is_current(expected) {
return false;
}
Self::write_projection(&state, None);
if authority.clear_if_current(expected) {
state.installed = None;
true
} else {
let restore = state
.installed
.as_ref()
.filter(|installed| authority.is_current(installed.stamp))
.and_then(|installed| installed.session_id.clone());
Self::write_projection(&state, restore);
false
}
}
}
/// Map the effective local playback ceiling to the exact official LAN enum.
/// The same `local_playback_quality` source is used by stream resolution and
/// cloud renderer reports; LAN must not advertise an independent capability.
pub const fn max_audio_quality_for_quality(quality: Quality) -> MaxAudioQuality {
match quality {
Quality::Mp3 => MaxAudioQuality::MP3,
Quality::Lossless => MaxAudioQuality::UpToCd,
Quality::HiRes => MaxAudioQuality::UpToHires96,
Quality::UltraHiRes => MaxAudioQuality::UpToHires192,
}
}
#[derive(Debug)]
pub enum QtLanError {
ExistingIdentityUnavailable,
Service(LanError),
AdmissionThread(std::io::Error),
}
impl fmt::Display for QtLanError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ExistingIdentityUnavailable => {
formatter.write_str("qconnect LAN identity is unavailable")
}
Self::Service(error) => write!(formatter, "qconnect LAN service failed: {error}"),
Self::AdmissionThread(_) => {
formatter.write_str("qconnect LAN admission thread could not start")
}
}
}
}
impl Error for QtLanError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Service(error) => Some(error),
Self::AdmissionThread(error) => Some(error),
Self::ExistingIdentityUnavailable => None,
}
}
}
impl From<LanError> for QtLanError {
fn from(error: LanError) -> Self {
Self::Service(error)
}
}
struct ExistingIdentity {
device_uuid: String,
friendly_name: String,
brand: String,
software_version: String,
}
impl ExistingIdentity {
fn resolve() -> Result<Self, QtLanError> {
let device_info = default_qconnect_device_info();
let (Some(device_uuid), Some(friendly_name), Some(brand), Some(software_version)) = (
device_info.device_uuid,
device_info.friendly_name,
device_info.brand,
device_info.software_version,
) else {
return Err(QtLanError::ExistingIdentityUnavailable);
};
if device_uuid.trim().is_empty()
|| friendly_name.trim().is_empty()
|| brand.trim().is_empty()
|| software_version.trim().is_empty()
{
return Err(QtLanError::ExistingIdentityUnavailable);
}
Ok(Self {
device_uuid,
friendly_name,
brand,
software_version,
})
}
}
fn build_projection(
identity: &ExistingIdentity,
app_id: String,
max_audio_quality: MaxAudioQuality,
current_session_id: Option<String>,
) -> LanProjection {
LanProjection::new(
DisplayInfo {
friendly_name: identity.friendly_name.clone(),
serial_number: identity.device_uuid.clone(),
brand_display_name: identity.brand.clone(),
model_display_name: QT_MODEL.to_string(),
max_audio_quality,
device_type: DeviceType::Computer,
software_version: identity.software_version.clone(),
},
ConnectInfo {
app_id,
current_session_id,
},
)
}
/// Owns the Qt LAN service and its blocking admission bridge.
///
/// The callback future is driven on `runtime_handle` while the dedicated
/// bridge thread waits for it. Consequently, candidates are admitted in the
/// exact order observed from the latest-wins slot without blocking a Tokio
/// worker or the Qt UI thread. The callback must provide its own bounded and
/// cancellation-safe transaction semantics.
pub struct QtLanRuntime {
service: Option<LanService>,
projection: LanProjection,
admission_bridge: Option<JoinHandle<()>>,
}
impl QtLanRuntime {
pub fn start<F, Fut>(
runtime_handle: tokio::runtime::Handle,
endpoint_policy: EndpointPolicy,
app_id: String,
max_audio_quality: MaxAudioQuality,
current_session_id: Option<String>,
on_handoff: F,
) -> Result<Self, QtLanError>
where
F: Fn(HandoffCandidate) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let identity = ExistingIdentity::resolve()?;
let projection = build_projection(&identity, app_id, max_audio_quality, current_session_id);
let config =
LanServiceConfig::new(projection.clone(), endpoint_policy, identity.device_uuid);
let (mut service, inbox) = LanService::start(config)?;
let on_handoff = Arc::new(on_handoff);
let admission_bridge = match std::thread::Builder::new()
.name(ADMISSION_THREAD_NAME.to_string())
.spawn(move || loop {
match inbox.take_timeout(ADMISSION_WAIT) {
Some(candidate) => {
let callback = Arc::clone(&on_handoff);
runtime_handle.block_on(callback(candidate));
}
None if inbox.is_closed() => break,
None => {}
}
}) {
Ok(thread) => thread,
Err(error) => {
service.shutdown();
return Err(QtLanError::AdmissionThread(error));
}
};
Ok(Self {
service: Some(service),
projection,
admission_bridge: Some(admission_bridge),
})
}
pub fn projection(&self) -> LanProjection {
self.projection.clone()
}
pub fn port(&self) -> Option<u16> {
self.service.as_ref().map(LanService::port)
}
/// Idempotent blocking teardown in the normative order: withdraw
/// discovery and stop HTTP admission first, then wait for the now-woken
/// bridge to exit. Callers in async or UI contexts must run this method on
/// a blocking worker.
pub fn shutdown_blocking(&mut self) {
if let Some(mut service) = self.service.take() {
service.shutdown();
}
if let Some(bridge) = self.admission_bridge.take() {
let _ = bridge.join();
}
}
}
impl Drop for QtLanRuntime {
fn drop(&mut self) {
self.shutdown_blocking();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_identity() -> ExistingIdentity {
ExistingIdentity {
device_uuid: "de0c0d70-0000-4000-8000-000000000001".to_string(),
friendly_name: "Desktop probe".to_string(),
brand: "QBZ".to_string(),
software_version: "qbz/2.1.0".to_string(),
}
}
#[test]
fn desktop_projection_uses_existing_identity_and_official_role() {
let projection = build_projection(
&fixture_identity(),
"app-id".to_string(),
MaxAudioQuality::UpToHires192,
None,
);
assert_eq!(
projection.display_info(),
DisplayInfo {
friendly_name: "Desktop probe".to_string(),
serial_number: "de0c0d70-0000-4000-8000-000000000001".to_string(),
brand_display_name: "QBZ".to_string(),
model_display_name: "QBZ Desktop".to_string(),
max_audio_quality: MaxAudioQuality::UpToHires192,
device_type: DeviceType::Computer,
software_version: "qbz/2.1.0".to_string(),
}
);
assert_eq!(
projection.connect_info(),
ConnectInfo {
app_id: "app-id".to_string(),
current_session_id: None,
}
);
}
#[test]
fn quality_mapping_matches_official_lan_families() {
assert_eq!(
max_audio_quality_for_quality(Quality::Mp3),
MaxAudioQuality::MP3
);
assert_eq!(
max_audio_quality_for_quality(Quality::Lossless),
MaxAudioQuality::UpToCd
);
assert_eq!(
max_audio_quality_for_quality(Quality::HiRes),
MaxAudioQuality::UpToHires96
);
assert_eq!(
max_audio_quality_for_quality(Quality::UltraHiRes),
MaxAudioQuality::UpToHires192
);
}
#[test]
fn projection_session_id_changes_only_when_explicitly_updated() {
let projection = build_projection(
&fixture_identity(),
"app-id".to_string(),
MaxAudioQuality::UpToCd,
None,
);
assert_eq!(projection.connect_info().current_session_id, None);
projection.set_current_session_id(Some("delegated-session".to_string()));
assert_eq!(
projection.connect_info().current_session_id.as_deref(),
Some("delegated-session")
);
projection.set_current_session_id(None);
assert_eq!(projection.connect_info().current_session_id, None);
}
#[test]
fn stamped_projection_publishes_only_installed_authority() {
let authority = AuthorityCell::new();
let slot = QtLanProjectionSlot::default();
let projection = build_projection(
&fixture_identity(),
"app-id".to_string(),
MaxAudioQuality::UpToCd,
None,
);
slot.attach(&authority, projection.clone());
let owner = authority.reserve(AuthorityOrigin::Owner);
assert!(slot.install_authority(&authority, owner, None));
assert!(slot.confirm_owner_session(&authority, owner, "owner-session"));
assert_eq!(
projection.connect_info().current_session_id.as_deref(),
Some("owner-session")
);
// Merely reserving/preparing a guest cannot expose its candidate id.
let guest = authority.reserve(AuthorityOrigin::Delegated { generation: 7 });
assert_eq!(
projection.connect_info().current_session_id.as_deref(),
Some("owner-session")
);
assert!(slot.install_authority(&authority, guest, Some("guest-session")));
assert_eq!(
projection.connect_info().current_session_id.as_deref(),
Some("guest-session")
);
// A late event from the retired owner cannot overwrite the guest.
assert!(!slot.confirm_owner_session(&authority, owner, "stale-owner-session"));
// Delegated events never replace the handoff id either; only their
// stamped install is allowed to publish it.
assert!(!slot.confirm_owner_session(&authority, guest, "delegated-event-session"));
assert_eq!(
projection.connect_info().current_session_id.as_deref(),
Some("guest-session")
);
}
#[test]
fn owner_session_events_update_and_stale_clear_cannot_win() {
let authority = AuthorityCell::new();
let slot = QtLanProjectionSlot::default();
let projection = build_projection(
&fixture_identity(),
"app-id".to_string(),
MaxAudioQuality::UpToCd,
None,
);
slot.attach(&authority, projection.clone());
let first_owner = authority.reserve(AuthorityOrigin::Owner);
assert!(slot.install_authority(&authority, first_owner, None));
assert!(slot.confirm_owner_session(&authority, first_owner, "owner-session-a"));
assert!(slot.confirm_owner_session(&authority, first_owner, "owner-session-b"));
assert_eq!(
slot.current_session_id(&authority, first_owner).as_deref(),
Some("owner-session-b")
);
let next_owner = authority.reserve(AuthorityOrigin::Owner);
assert!(slot.install_authority(&authority, next_owner, Some("owner-session-c")));
assert!(!slot.install_authority(&authority, first_owner, Some("stale-owner-session")));
assert!(!slot.clear_if_current(&authority, first_owner));
assert_eq!(
projection.connect_info().current_session_id.as_deref(),
Some("owner-session-c")
);
}
}