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:
5 files changed
+186
No files matched your search
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
Reference in new issue
Block a user