feat(qbzd): mDNS discovery + registration for LAN pairing

Daemon side: registers _qbz._tcp.local mDNS service on startup
so desktop apps can discover it on the LAN. Uses hostname as
service name (configurable via qbzd.toml).

Desktop side: new qbzd_discovery.rs module with Tauri commands:
- v2_qbzd_start_discovery: browse _qbz._tcp services
- v2_qbzd_stop_discovery: stop browsing
- v2_qbzd_get_devices: list discovered daemons

Uses same mdns-sd crate already used for AirPlay discovery.

cc
This commit is contained in:
vicrodh committed 2026-04-09 04:30:08 -06:00
1 parent 8dac185261
commit 8fc6111782
5 files changed
+186

No files matched your search

+4
View File
@@ -44,6 +44,10 @@ log = { workspace = true }
env_logger = "0.11"
sysinfo = "0.34"
# mDNS discovery
mdns-sd = "0.11"
hostname = "0.4"
# Database
rusqlite = { version = "0.31", features = ["bundled"] }
+46
View File
@@ -102,6 +102,22 @@ pub async fn run(mut config: DaemonConfig) -> Result<(), String> {
// Start playback state polling loop (broadcasts to event bus)
spawn_playback_loop(daemon.core.clone(), daemon.event_bus.clone());
// Register mDNS service for LAN discovery
let _mdns_handle = if config.mdns.enabled {
match register_mdns(&config) {
Ok(handle) => {
log::info!("[qbzd] mDNS registered: _qbz._tcp on port {}", config.server.port);
Some(handle)
}
Err(e) => {
log::warn!("[qbzd] mDNS registration failed: {}", e);
None
}
}
} else {
None
};
log::info!(
"[qbzd] Daemon ready on {}:{}",
config.server.bind,
@@ -195,6 +211,36 @@ fn load_oauth_token() -> Option<String> {
}
}
/// Register the daemon as a `_qbz._tcp` mDNS service for LAN discovery.
fn register_mdns(config: &DaemonConfig) -> Result<mdns_sd::ServiceDaemon, String> {
let mdns = mdns_sd::ServiceDaemon::new()
.map_err(|e| format!("mDNS daemon error: {}", e))?;
let service_name = if config.mdns.name.is_empty() {
hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.unwrap_or_else(|| "qbzd".to_string())
} else {
config.mdns.name.clone()
};
let service_info = mdns_sd::ServiceInfo::new(
"_qbz._tcp.local.",
&service_name,
&format!("{}.", hostname::get().ok().and_then(|h| h.into_string().ok()).unwrap_or_else(|| "localhost".to_string())),
"",
config.server.port,
None,
)
.map_err(|e| format!("mDNS service info error: {}", e))?;
mdns.register(service_info)
.map_err(|e| format!("mDNS register error: {}", e))?;
Ok(mdns)
}
/// Spawn the playback state polling loop.
/// Reads player state and broadcasts PlaybackSnapshot events.
/// Adaptive polling: 250ms playing, 1s paused, 5s idle.
+1
View File
@@ -28,6 +28,7 @@ qbz-cache = { path = "../crates/qbz-cache" }
qbz-library = { path = "../crates/qbz-library" }
qbz-cast = { path = "../crates/qbz-cast" }
qbz-cmaf = { path = "../crates/qbz-cmaf" }
mdns-sd = "0.11"
qconnect-app = { path = "../crates/qconnect-app" }
qconnect-core = { path = "../crates/qconnect-core" }
qconnect-transport-ws = { path = "../crates/qconnect-transport-ws" }
+5
View File
@@ -59,6 +59,7 @@ pub mod tray;
pub mod updates;
pub mod user_data;
pub mod visualizer;
pub mod qbzd_discovery;
use rustls::crypto::{aws_lc_rs, CryptoProvider};
use std::sync::Arc;
@@ -756,6 +757,7 @@ pub fn run() {
))
.manage(core_bridge::CoreBridgeState::new())
.manage(commands_v2::OAuthCancelState::new())
.manage(qbzd_discovery::QbzdDiscoveryState::new())
.manage(runtime::RuntimeManagerState::new())
.manage(qconnect_service::QconnectServiceState::new())
.manage(user_data_paths)
@@ -1224,6 +1226,9 @@ pub fn run() {
commands_v2::v2_start_system_browser_oauth,
commands_v2::v2_cancel_oauth_login,
commands_v2::v2_cancel_system_browser_oauth,
qbzd_discovery::v2_qbzd_start_discovery,
qbzd_discovery::v2_qbzd_stop_discovery,
qbzd_discovery::v2_qbzd_get_devices,
commands_v2::v2_get_user_info,
commands_v2::v2_save_credentials,
commands_v2::v2_clear_saved_credentials,
+130
View File
@@ -0,0 +1,130 @@
//! mDNS discovery for qbzd daemon instances on the LAN.
//!
//! Uses the same mdns-sd crate as AirPlay discovery in qbz-cast.
//! Discovers `_qbz._tcp.local` services.
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::Mutex;
const QBZD_SERVICE_TYPE: &str = "_qbz._tcp.local.";
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QbzdDevice {
pub name: String,
pub host: String,
pub port: u16,
pub base_url: String,
}
pub struct QbzdDiscoveryState {
devices: Arc<Mutex<Vec<QbzdDevice>>>,
daemon: Arc<Mutex<Option<mdns_sd::ServiceDaemon>>>,
}
impl QbzdDiscoveryState {
pub fn new() -> Self {
Self {
devices: Arc::new(Mutex::new(Vec::new())),
daemon: Arc::new(Mutex::new(None)),
}
}
}
#[tauri::command]
pub async fn v2_qbzd_start_discovery(
state: tauri::State<'_, QbzdDiscoveryState>,
) -> Result<(), String> {
let mut daemon_guard = state.daemon.lock().await;
// Stop existing discovery if running
if let Some(d) = daemon_guard.take() {
let _ = d.shutdown();
}
state.devices.lock().await.clear();
let mdns = mdns_sd::ServiceDaemon::new()
.map_err(|e| format!("Failed to create mDNS daemon: {}", e))?;
let receiver = mdns
.browse(QBZD_SERVICE_TYPE)
.map_err(|e| format!("Failed to browse qbzd services: {}", e))?;
let devices = state.devices.clone();
// Spawn background task to collect discovered devices
tokio::spawn(async move {
while let Ok(event) = receiver.recv_async().await {
match event {
mdns_sd::ServiceEvent::ServiceResolved(info) => {
let name = info
.get_fullname()
.split('.')
.next()
.unwrap_or("qbzd")
.to_string();
let host = info
.get_addresses()
.iter()
.next()
.map(|a| a.to_string())
.unwrap_or_else(|| info.get_hostname().trim_end_matches('.').to_string());
let port = info.get_port();
let base_url = format!("http://{}:{}", host, port);
log::info!("[qbzd-discovery] Found: {} at {}", name, base_url);
let device = QbzdDevice {
name,
host,
port,
base_url,
};
let mut devs = devices.lock().await;
// Deduplicate by base_url
if !devs.iter().any(|d| d.base_url == device.base_url) {
devs.push(device);
}
}
mdns_sd::ServiceEvent::ServiceRemoved(_, fullname) => {
log::info!("[qbzd-discovery] Removed: {}", fullname);
let name = fullname
.split('.')
.next()
.unwrap_or("")
.to_string();
let mut devs = devices.lock().await;
devs.retain(|d| d.name != name);
}
_ => {}
}
}
});
*daemon_guard = Some(mdns);
log::info!("[qbzd-discovery] Discovery started");
Ok(())
}
#[tauri::command]
pub async fn v2_qbzd_stop_discovery(
state: tauri::State<'_, QbzdDiscoveryState>,
) -> Result<(), String> {
let mut daemon_guard = state.daemon.lock().await;
if let Some(d) = daemon_guard.take() {
let _ = d.shutdown();
log::info!("[qbzd-discovery] Discovery stopped");
}
Ok(())
}
#[tauri::command]
pub async fn v2_qbzd_get_devices(
state: tauri::State<'_, QbzdDiscoveryState>,
) -> Result<Vec<QbzdDevice>, String> {
let devs = state.devices.lock().await;
Ok(devs.clone())
}