551 lines
19 KiB
Rust
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")
|
|
);
|
|
}
|
|
}
|