From eda8f7d927f40e2f151136b19bbf836ddadba399 Mon Sep 17 00:00:00 2001 From: QianheYu Date: Sat, 30 May 2026 22:04:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20ADP=E5=8D=8F=E8=AE=AE=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20+=20=E5=A4=9A=E6=98=BE=E7=A4=BA=E5=99=A8?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20+=20=E5=B1=8F=E5=B9=95=E9=87=87=E9=9B=86?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20+=20=E6=B5=81=E5=BC=8F=E7=AE=A1=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADP 协议修复: codecs 字段名匹配 Android 端,protocol_version 解析为字符串 - mDNS 发现: 返回结构化 DeviceInfo(设备名/地址/分辨率/TXT记录) - 屏幕采集: macOS CGDisplay + Windows captrs 实现(Linux 仍为 xrandr 分辨率检测 stub) - 新增 display 模块: 跨平台显示器枚举(list_displays),支持多显示器选择 - 流式管道: start_streaming 实现 capture→encode→send 完整链路,支持 display_index 参数 - 前端: disconnect 调用 backend,设备列表/连接面板展示名称与分辨率 - Encoder: 添加 bitrate() getter - get_display_resolution: 改为查询实际显示器分辨率 --- src-tauri/src/adp/client.rs | 8 +- src-tauri/src/capture/linux.rs | 49 ++++-- src-tauri/src/capture/macos.rs | 55 ++++++- src-tauri/src/capture/platform.rs | 20 ++- src-tauri/src/capture/windows.rs | 48 ++++-- src-tauri/src/display.rs | 237 +++++++++++++++++++++++++++++ src-tauri/src/encoder/soft.rs | 4 + src-tauri/src/lib.rs | 132 ++++++++++++---- src-tauri/src/mdns/discovery.rs | 69 ++++++--- src/App.tsx | 29 +++- src/components/ConnectionPanel.tsx | 10 +- src/components/DeviceList.tsx | 49 ++++-- 12 files changed, 605 insertions(+), 105 deletions(-) create mode 100644 src-tauri/src/display.rs diff --git a/src-tauri/src/adp/client.rs b/src-tauri/src/adp/client.rs index 52d3441..28dcbce 100644 --- a/src-tauri/src/adp/client.rs +++ b/src-tauri/src/adp/client.rs @@ -213,12 +213,16 @@ impl AdpClient { serde_json::from_slice(&server_cap.payload).context("Invalid Capability JSON")?; let server_cap = ServerCapability { - protocol_version: cap_json["protocol_version"].as_u64().unwrap_or(1) as u8, + protocol_version: cap_json["protocol_version"] + .as_str() + .and_then(|s| s.split('.').next()) + .and_then(|s| s.parse().ok()) + .unwrap_or(1) as u8, device_name: cap_json["device_name"] .as_str() .unwrap_or("AirDisplay") .to_string(), - supported_codecs: cap_json["supported_codecs"] + supported_codecs: cap_json["codecs"] .as_array() .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(), diff --git a/src-tauri/src/capture/linux.rs b/src-tauri/src/capture/linux.rs index 3697170..b80436b 100644 --- a/src-tauri/src/capture/linux.rs +++ b/src-tauri/src/capture/linux.rs @@ -1,8 +1,11 @@ -//! Linux screen capture (PipeWire / X11 stub) +//! Linux screen capture stub. //! -//! TODO: Implement using `pipewire` or `x11` + `xcb` crates. +//! TODO: Implement via PipeWire (Wayland) or X11 SHM (X11). +//! Use `pipewire` crate for Wayland or `x11`/`xcb` crate for X11. + +use anyhow::{bail, Context, Result}; +use std::process::Command; -use anyhow::{bail, Result}; use super::CapturedFrame; pub struct LinuxCapturer { @@ -13,18 +16,44 @@ pub struct LinuxCapturer { impl LinuxCapturer { pub fn new(fps: u32) -> Result { - Ok(Self { - width: 1920, - height: 1080, - fps, - }) + let (width, height) = detect_resolution().unwrap_or((1920, 1080)); + Ok(Self { width, height, fps }) } + + pub fn for_display(_display: u32, fps: u32) -> Result { + Self::new(fps) + } +} + +/// Try to detect screen resolution via xrandr. +fn detect_resolution() -> Option<(u32, u32)> { + let output = Command::new("xrandr") + .output() + .ok()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.contains(" connected ") { + // Parse: "HDMI-1 connected primary 1920x1080+0+0" + if let Some(mode) = line.split_whitespace().find(|s| s.contains('x')) { + // mode looks like "1920x1080+0+0" + let parts: Vec<&str> = mode.split(&['x', '+'][..]).collect(); + if parts.len() >= 2 { + if let (Ok(w), Ok(h)) = (parts[0].parse::(), parts[1].parse::()) { + return Some((w, h)); + } + } + } + } + } + None } impl super::Capturer for LinuxCapturer { fn capture_frame(&mut self) -> Result { - // FIXME: capture using PipeWire or X11/Shm - bail!("Linux capture not yet implemented") + // Screen capture requires PipeWire (Wayland) or X11 SHM (X11). + // For development, use the desktop client on macOS or Windows. + bail!("Linux capture not yet implemented (requires PipeWire or X11 integration)") } fn resolution(&self) -> (u32, u32) { diff --git a/src-tauri/src/capture/macos.rs b/src-tauri/src/capture/macos.rs index cdedd88..430eb02 100644 --- a/src-tauri/src/capture/macos.rs +++ b/src-tauri/src/capture/macos.rs @@ -1,21 +1,45 @@ -//! macOS screen capture (CGDisplay stub) +//! macOS screen capture using Core Graphics (CGDisplay). //! -//! TODO: Implement using `core-graphics` or ScreenCaptureKit. +//! Captures the primary display as raw RGBA pixel data. +//! For multi-display support, pass a display_id to select which monitor. + +use anyhow::{Context, Result}; +use core_graphics::display::{CGDisplay, CGDisplayBounds}; +use core_graphics::image::CGImage; -use anyhow::{bail, Result}; use super::CapturedFrame; pub struct MacOsCapturer { + display_id: u32, width: u32, height: u32, fps: u32, } impl MacOsCapturer { + /// Create a capturer for the primary display. pub fn new(fps: u32) -> Result { + let main = CGDisplay::main(); + let bounds = CGDisplayBounds(main.id); + let width = bounds.size.width as u32; + let height = bounds.size.height as u32; Ok(Self { - width: 1920, - height: 1080, + display_id: main.id, + width, + height, + fps, + }) + } + + /// Create a capturer for a specific display. + pub fn for_display(display_id: u32, fps: u32) -> Result { + let bounds = CGDisplayBounds(display_id); + let width = bounds.size.width as u32; + let height = bounds.size.height as u32; + Ok(Self { + display_id, + width, + height, fps, }) } @@ -23,8 +47,25 @@ impl MacOsCapturer { impl super::Capturer for MacOsCapturer { fn capture_frame(&mut self) -> Result { - // FIXME: capture using CGDisplay or SCK - bail!("macOS capture not yet implemented") + let image = CGDisplay::screenshot( + self.display_id, + self.width as usize, + self.height as usize, + ) + .context("Failed to capture display screenshot")?; + + let image_width = image.width() as u32; + let image_height = image.height() as u32; + + let provider = image.data_provider().context("No image data provider")?; + let raw_data = provider.data().to_vec(); + + Ok(CapturedFrame { + data: raw_data, + width: image_width, + height: image_height, + stride: image_width * 4, // RGBA = 4 bytes per pixel + }) } fn resolution(&self) -> (u32, u32) { diff --git a/src-tauri/src/capture/platform.rs b/src-tauri/src/capture/platform.rs index b171d11..972ff67 100644 --- a/src-tauri/src/capture/platform.rs +++ b/src-tauri/src/capture/platform.rs @@ -34,21 +34,31 @@ pub trait Capturer: Send { fn resolution(&self) -> (u32, u32); } -/// Create a platform-appropriate screen capturer. -pub fn create_capturer(fps: u32) -> Result> { +/// Create a platform-appropriate screen capturer for the given display index. +/// `display_index`: 0 = primary display, 1+ = secondary displays. +pub fn create_capturer(display_index: usize, fps: u32) -> Result> { #[cfg(target_os = "windows")] { - let capturer = windows::WindowsCapturer::new(fps)?; + let capturer = windows::WindowsCapturer::for_display(display_index)?; Ok(Box::new(capturer)) } #[cfg(target_os = "macos")] { - let capturer = macos::MacOsCapturer::new(fps)?; + let capturer = if display_index == 0 { + macos::MacOsCapturer::new(fps)? + } else { + let displays = crate::display::list_displays(); + let id = displays + .get(display_index) + .map(|d| d.index) + .unwrap_or(0); + macos::MacOsCapturer::for_display(id, fps)? + }; Ok(Box::new(capturer)) } #[cfg(target_os = "linux")] { - let capturer = linux::LinuxCapturer::new(fps)?; + let capturer = linux::LinuxCapturer::for_display(display_index as u32, fps)?; Ok(Box::new(capturer)) } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] diff --git a/src-tauri/src/capture/windows.rs b/src-tauri/src/capture/windows.rs index b0ee037..d664e32 100644 --- a/src-tauri/src/capture/windows.rs +++ b/src-tauri/src/capture/windows.rs @@ -1,31 +1,59 @@ -//! Windows screen capture (DXGI Desktop Duplication API stub) +//! Windows screen capture using captrs (DXGI Desktop Duplication API). //! -//! TODO: Implement using `dxgi` + `d3d11` crates or `captrs`. +//! Captures the primary display as raw RGBA pixel data. + +use anyhow::{Context, Result}; +use captrs::*; -use anyhow::{bail, Result}; use super::CapturedFrame; pub struct WindowsCapturer { + capturer: Capturer, width: u32, height: u32, - fps: u32, } impl WindowsCapturer { pub fn new(fps: u32) -> Result { - // FIXME: init DXGI duplication + let capturer = Capturer::new(0).context("Failed to create DXGI screen capturer")?; + let (width, height) = capturer.geometry(); Ok(Self { - width: 1920, - height: 1080, - fps, + capturer, + width: width as u32, + height: height as u32, + }) + } + + /// Create a capturer for a specific display index. + pub fn for_display(display_index: usize, fps: u32) -> Result { + let capturer = Capturer::new(display_index).context("Failed to create DXGI screen capturer for display")?; + let (width, height) = capturer.geometry(); + Ok(Self { + capturer, + width: width as u32, + height: height as u32, }) } } impl super::Capturer for WindowsCapturer { fn capture_frame(&mut self) -> Result { - // FIXME: capture using DXGI OutputDuplication - bail!("Windows capture not yet implemented") + let captrs_frame = self + .capturer + .capture_frame() + .context("Failed to capture desktop frame via DXGI")?; + + // captrs encodes as BGRA8 internally, convert to RGBA + let raw = captrs_frame.to_rgba(); + let w = self.width; + let h = self.height; + + Ok(CapturedFrame { + data: raw, + width: w, + height: h, + stride: w * 4, + }) } fn resolution(&self) -> (u32, u32) { diff --git a/src-tauri/src/display.rs b/src-tauri/src/display.rs new file mode 100644 index 0000000..c442aba --- /dev/null +++ b/src-tauri/src/display.rs @@ -0,0 +1,237 @@ +//! Cross-platform display enumeration and resolution detection. +//! +//! Detects available displays/monitors and their properties: +//! - Display index, name, resolution, position, primary status +//! +//! Used for multi-monitor extended display support. + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct DisplayInfo { + pub index: u32, + pub name: String, + pub width: u32, + pub height: u32, + pub x: i32, + pub y: i32, + pub is_primary: bool, +} + +/// Get information about all connected displays. +pub fn list_displays() -> Vec { + #[cfg(target_os = "windows")] + { windows_displays() } + + #[cfg(target_os = "macos")] + { macos_displays() } + + #[cfg(target_os = "linux")] + { linux_displays() } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { default_displays() } +} + +/// Get the primary display resolution. +pub fn primary_display_resolution() -> (u32, u32) { + let displays = list_displays(); + displays + .iter() + .find(|d| d.is_primary) + .or_else(|| displays.first()) + .map(|d| (d.width, d.height)) + .unwrap_or((1920, 1080)) +} + +// ── Platform-specific implementations ── + +#[cfg(target_os = "windows")] +fn windows_displays() -> Vec { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use std::mem; + + extern "system" { + fn GetDC(hwnd: isize) -> isize; + fn ReleaseDC(hwnd: isize, hdc: isize) -> i32; + fn GetDeviceCaps(hdc: isize, index: i32) -> i32; + fn EnumDisplayMonitors( + hdc: isize, + clip_rect: *const u8, + callback: extern "system" fn(isize, isize, *mut u8, isize) -> i32, + data: isize, + ) -> i32; + fn GetMonitorInfoW(monitor: isize, info: *mut u8) -> i32; + fn EnumDisplayDevicesW( + device: *const u16, + dev_idx: u32, + disp_device: *mut u8, + flags: u32, + ) -> i32; + } + + struct MonitorEnumData { + displays: Vec, + } + + unsafe extern "system" fn monitor_callback( + monitor: isize, + _hdc: isize, + _rect: *mut u8, + data: isize, + ) -> i32 { + let enum_data = &mut *(data as *mut MonitorEnumData); + let index = enum_data.displays.len() as u32; + + #[repr(C)] + struct MonitorInfo { + size: u32, + rect: [i32; 4], // left, top, right, bottom + work_rect: [i32; 4], // left, top, right, bottom + flags: u32, + } + + let mut info = MonitorInfo { + size: mem::size_of::() as u32, + rect: [0; 4], + work_rect: [0; 4], + flags: 0, + }; + + let ret = GetMonitorInfoW(monitor, &mut info as *mut _ as *mut u8); + if ret != 0 { + let is_primary = (info.flags & 0x01) != 0; + let width = (info.rect[2] - info.rect[0]) as u32; + let height = (info.rect[3] - info.rect[1]) as u32; + + #[repr(C)] + struct DisplayDevice { + size: u32, + device_name: [u16; 32], + device_string: [u16; 128], + state_flags: u32, + device_id: [u16; 128], + device_key: [u16; 128], + } + + let mut dev = DisplayDevice { + size: mem::size_of::() as u32, + device_name: [0; 32], + device_string: [0; 128], + state_flags: 0, + device_id: [0; 128], + device_key: [0; 128], + }; + + let name = if EnumDisplayDevicesW( + std::ptr::null(), + index, + &mut dev as *mut _ as *mut u8, + 0, + ) != 0 + { + let end = dev.device_string.iter().position(|&c| c == 0).unwrap_or(128); + OsString::from_wide(&dev.device_string[..end]) + .to_string_lossy() + .to_string() + } else { + format!("Display {}", index + 1) + }; + + enum_data.displays.push(DisplayInfo { + index, + name, + width, + height, + x: info.rect[0], + y: info.rect[1], + is_primary, + }); + } + 1 // Continue enumeration + } + + let mut data = MonitorEnumData { + displays: Vec::new(), + }; + + unsafe { + EnumDisplayMonitors( + 0, + std::ptr::null(), + monitor_callback, + &mut data as *mut _ as isize, + ); + } + + if data.displays.is_empty() { + // Fallback: get primary display via screen DC + let width = 1920u32; + let height = 1080u32; + data.displays.push(DisplayInfo { + index: 0, + name: "Primary Display".to_string(), + width, + height, + x: 0, + y: 0, + is_primary: true, + }); + } + + data.displays +} + +#[cfg(target_os = "macos")] +fn macos_displays() -> Vec { + use core_graphics::display::{CGDisplay, CGDisplayBounds}; + + let count = CGDisplay::active_count(); + let main_id = CGDisplay::main().id; + + (0..count) + .filter_map(|i| { + let display = CGDisplay::online_displays()?.get(i as usize)?; + let bounds = CGDisplayBounds(display); + let is_primary = display == main_id; + Some(DisplayInfo { + index: i, + name: format!("Display {}", i + 1), + width: bounds.size.width as u32, + height: bounds.size.height as u32, + x: bounds.origin.x as i32, + y: bounds.origin.y as i32, + is_primary, + }) + }) + .collect() +} + +#[cfg(target_os = "linux")] +fn linux_displays() -> Vec { + // Linux display enumeration requires X11 or Wayland. + // For now, return a sensible default. + // TODO: implement via xrandr / libxcb / pipewire + vec![DisplayInfo { + index: 0, + name: "Primary Display".to_string(), + width: 1920, + height: 1080, + x: 0, + y: 0, + is_primary: true, + }] +} + +fn default_displays() -> Vec { + vec![DisplayInfo { + index: 0, + name: "Primary Display".to_string(), + width: 1920, + height: 1080, + x: 0, + y: 0, + is_primary: true, + }] +} diff --git a/src-tauri/src/encoder/soft.rs b/src-tauri/src/encoder/soft.rs index fc16c27..f9cac33 100644 --- a/src-tauri/src/encoder/soft.rs +++ b/src-tauri/src/encoder/soft.rs @@ -111,6 +111,10 @@ impl SoftwareEncoder { (self.width, self.height) } + pub fn bitrate(&self) -> u64 { + self.bitrate + } + /// Convert raw RGBA pixel data to an ffmpeg YUV420P frame. /// /// Uses row-by-row copy to handle ffmpeg's internal stride alignment, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 64c958a..cb441c7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,17 +1,17 @@ +use std::sync::Arc; use tauri::Manager; use tokio::sync::Mutex as TokioMutex; mod adp; mod capture; +mod display; mod encoder; mod mdns; // ── Application State ── struct AppState { - /// ADP client connection (stored to keep the background read loop alive) client: TokioMutex>, - /// H.264 encoder instance encoder: TokioMutex>, } @@ -37,8 +37,9 @@ fn get_version() -> String { } /// Discover AirDisplay devices on LAN via mDNS. +/// Returns structured device info with name, address, resolution, platform. #[tauri::command] -async fn discover_devices() -> Result, String> { +async fn discover_devices() -> Result, String> { let mut discovery = mdns::MdnsDiscovery::new().map_err(|e| e.to_string())?; let devices = tokio::task::spawn_blocking(move || discovery.discover()) .await @@ -155,52 +156,124 @@ async fn send_frame( /// Start the full streaming pipeline (capture → encode → send). /// Spawns a background task that runs at the configured FPS. +/// `display_index` selects which monitor to capture (0 = primary). #[tauri::command] async fn start_streaming( fps: u32, + display_index: Option, state: tauri::State<'_, AppState>, app_handle: tauri::AppHandle, ) -> Result { - let mut enc_guard = state.encoder.lock().await; - let encoder = enc_guard - .as_mut() - .ok_or_else(|| "Encoder not initialized. Call init_encoder first.".to_string())?; + let enc_guard = state.encoder.lock().await; + let (enc_width, enc_height, enc_bitrate) = { + let encoder = enc_guard + .as_ref() + .ok_or_else(|| "Encoder not initialized. Call init_encoder first.".to_string())?; + (encoder.resolution().0, encoder.resolution().1, encoder.bitrate()) + }; + drop(enc_guard); - let mut cli_guard = state.client.lock().await; - let client = cli_guard - .as_mut() - .ok_or_else(|| "Client not connected. Call connect_adp first.".to_string())?; + let has_client = { + let cli_guard = state.client.lock().await; + cli_guard.as_ref().map(|c| c.is_connected()).unwrap_or(false) + }; - if !client.is_connected() { - return Err("Client not connected".to_string()); + if !has_client { + return Err("Client not connected. Call connect_adp first.".to_string()); } - // TODO: implement the full capture loop - // let mut capturer = capture::create_capturer(fps)?; - // loop { - // let frame = capturer.capture_frame()?; - // let nals = encoder.encode(&frame.data)?; - // for nal in &nals { - // client.send_video_frame(nal).await?; - // } - // } + let idx = display_index.unwrap_or(0); + let mut capturer = capture::create_capturer(idx, fps) + .map_err(|e| format!("Failed to create capturer: {}", e))?; - Err("Streaming loop not yet implemented (capture module is a stub)".to_string()) + let capture_ms = (1000u64 / fps as u64).max(1); + tracing::info!( + "Starting streaming loop: {}x{} @ {}fps, {}bps, display={}", + enc_width, enc_height, fps, enc_bitrate, idx + ); + + tokio::spawn(async move { + let cycle = std::time::Duration::from_millis(capture_ms); + + loop { + let frame_start = std::time::Instant::now(); + + let captured = match capturer.capture_frame() { + Ok(f) => f, + Err(e) => { + tracing::error!("Capture error: {}", e); + break; + } + }; + + let app_state = app_handle.state::(); + let nals = { + let mut enc_guard = app_state.encoder.lock().await; + if let Some(ref mut enc) = *enc_guard { + match enc.encode(&captured.data) { + Ok(n) => n, + Err(e) => { + tracing::error!("Encode error: {}", e); + vec![] + } + } + } else { + vec![] + } + }; + + if !nals.is_empty() { + let mut cli_guard = app_state.client.lock().await; + if let Some(ref mut client) = *cli_guard { + for nal in &nals { + if let Err(e) = client.send_video_frame(nal).await { + tracing::error!("Send error: {}", e); + break; + } + } + } + } + + let elapsed = frame_start.elapsed(); + if elapsed < cycle { + tokio::time::sleep(cycle - elapsed).await; + } + } + + // Flush remaining encoder data + let app_state = app_handle.state::(); + let mut enc_guard = app_state.encoder.lock().await; + if let Some(ref mut enc) = *enc_guard { + let _ = enc.flush(); + } + }); + + Ok(format!( + "Streaming started: {}x{} @ {}fps on display {}", + enc_width, enc_height, fps, idx + )) } -/// Start screen capture (stub — returns display info). +/// Start screen capture (returns display info for the selected display). #[tauri::command] -async fn start_capture(fps: u32) -> Result { - let mut capturer = capture::create_capturer(fps).map_err(|e| e.to_string())?; +async fn start_capture(fps: u32, display_index: Option) -> Result { + let idx = display_index.unwrap_or(0); + let mut capturer = capture::create_capturer(idx, fps).map_err(|e| e.to_string())?; let (w, h) = capturer.resolution(); - Ok(format!("Capture started: {}x{} @ {}fps", w, h, fps)) + Ok(format!("Capture started: {}x{} @ {}fps (display {})", w, h, fps, idx)) } /// Get current display resolution. #[tauri::command] async fn get_display_resolution() -> Result<(u32, u32), String> { - // TODO: query actual display resolution - Ok((1920, 1080)) + let (w, h) = display::primary_display_resolution(); + Ok((w, h)) +} + +/// List all connected displays (multi-monitor support). +#[tauri::command] +async fn list_displays() -> Result, String> { + Ok(display::list_displays()) } // ── Application Entry Point ── @@ -220,6 +293,7 @@ pub fn run() { disconnect_adp, start_capture, get_display_resolution, + list_displays, init_encoder, send_frame, start_streaming, diff --git a/src-tauri/src/mdns/discovery.rs b/src-tauri/src/mdns/discovery.rs index 3c2ea23..c65d790 100644 --- a/src-tauri/src/mdns/discovery.rs +++ b/src-tauri/src/mdns/discovery.rs @@ -2,30 +2,39 @@ //! //! Discovers Android AirDisplay devices on the local network //! via `_airdisplay._tcp` service type. +//! +//! Returns structured `DeviceInfo` with name, address, port, +//! and TXT record metadata. use anyhow::Result; -use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo}; -use std::collections::HashSet; -use std::sync::{Arc, Mutex}; +use mdns_sd::{ServiceDaemon, ServiceEvent}; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; const SERVICE_TYPE: &str = "_airdisplay._tcp.local."; -const DISCOVERY_TIMEOUT_MS: u64 = 2000; +const DISCOVERY_TIMEOUT_MS: u64 = 3000; pub struct MdnsDiscovery { daemon: Arc, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct DeviceInfo { pub name: String, + pub host: String, pub addr: String, pub port: u16, + pub platform: String, + pub device_model: String, + pub max_width: u32, + pub max_height: u32, } impl std::fmt::Display for DeviceInfo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} ({}:{})", self.name, self.addr, self.port) + write!(f, "{} @ {}:{}", self.name, self.addr, self.port) } } @@ -37,27 +46,47 @@ impl MdnsDiscovery { }) } - /// Browse for devices and block briefly to collect results. - /// Returns list of discovered device addresses (ip:port). - pub fn discover(&mut self) -> Result> { + /// Browse for AirDisplay devices and return structured device info. + pub fn discover(&mut self) -> Result> { let receiver = self.daemon.browse(SERVICE_TYPE)?; - let mut devices = HashSet::new(); + let mut devices: HashMap = HashMap::new(); - // Collect devices for a short duration let deadline = std::time::Instant::now() + Duration::from_millis(DISCOVERY_TIMEOUT_MS); while std::time::Instant::now() < deadline { if let Ok(event) = receiver.try_recv() { match event { ServiceEvent::ServiceResolved(info) => { - let addr = info - .get_addresses() - .iter() - .next() - .map(|a| format!("{}:{}", a, info.get_port())) - .unwrap_or_default(); - if !addr.is_empty() { - devices.insert(addr); + let addrs = info.get_addresses(); + if addrs.is_empty() { + continue; } + let addr = addrs[0].to_string(); + let port = info.get_port(); + let key = format!("{}:{}", addr, port); + + if devices.contains_key(&key) { + continue; + } + + let txt_props = info.get_properties(); + let prop = |key: &str, default: &str| -> String { + txt_props + .as_ref() + .and_then(|m| m.get(key)) + .cloned() + .unwrap_or_else(|| default.to_string()) + }; + let device = DeviceInfo { + name: prop("device", "AirDisplay"), + host: info.get_hostname().trim_end_matches('.').to_string(), + addr, + port, + platform: prop("platform", "android"), + device_model: prop("device", "unknown"), + max_width: prop("max_width", "1920").parse().unwrap_or(1920), + max_height: prop("max_height", "1080").parse().unwrap_or(1080), + }; + devices.insert(key, device); } _ => {} } @@ -66,6 +95,6 @@ impl MdnsDiscovery { } } - Ok(devices.into_iter().collect()) + Ok(devices.into_values().collect()) } } diff --git a/src/App.tsx b/src/App.tsx index 938c3a9..3203aed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,17 @@ import { ConnectionPanel } from "./components/ConnectionPanel"; import { SettingsPanel } from "./components/SettingsPanel"; import "./App.css"; +interface DeviceInfo { + name: string; + host: string; + addr: string; + port: number; + platform: string; + device_model: string; + max_width: number; + max_height: number; +} + interface Settings { width: number; height: number; @@ -12,8 +23,8 @@ interface Settings { } function App() { - const [devices, setDevices] = useState([]); - const [selectedDevice, setSelectedDevice] = useState(null); + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(null); const [connected, setConnected] = useState(false); const [scanning, setScanning] = useState(false); const [settings, setSettings] = useState({ @@ -30,7 +41,7 @@ function App() { const refreshDevices = useCallback(async () => { setScanning(true); try { - const list: string[] = await invoke("discover_devices"); + const list: DeviceInfo[] = await invoke("discover_devices"); setDevices(list); } catch (e) { console.error("Discovery failed:", e); @@ -41,8 +52,9 @@ function App() { const handleConnect = useCallback(async () => { if (!selectedDevice) return; + const addr = `${selectedDevice.addr}:${selectedDevice.port}`; try { - const msg: string = await invoke("connect_adp", { addr: selectedDevice }); + const msg: string = await invoke("connect_adp", { addr }); console.log(msg); setConnected(true); } catch (e) { @@ -52,7 +64,14 @@ function App() { }, [selectedDevice]); const handleDisconnect = useCallback(async () => { - setConnected(false); + try { + await invoke("disconnect_adp"); + console.log("Disconnected"); + } catch (e) { + console.error("Disconnect failed:", e); + } finally { + setConnected(false); + } }, []); const handleApplySettings = useCallback(async () => { diff --git a/src/components/ConnectionPanel.tsx b/src/components/ConnectionPanel.tsx index 7401719..736b8da 100644 --- a/src/components/ConnectionPanel.tsx +++ b/src/components/ConnectionPanel.tsx @@ -1,5 +1,11 @@ +interface DeviceInfo { + name: string; + addr: string; + port: number; +} + interface ConnectionPanelProps { - selectedDevice: string | null; + selectedDevice: DeviceInfo | null; connected: boolean; onConnect: () => void; onDisconnect: () => void; @@ -16,7 +22,7 @@ export function ConnectionPanel({

Connection

- Device: {selectedDevice || "—"} + Device: {selectedDevice ? `${selectedDevice.name} (${selectedDevice.addr}:${selectedDevice.port})` : "—"}

Status:{" "} diff --git a/src/components/DeviceList.tsx b/src/components/DeviceList.tsx index ab82c80..c028e1f 100644 --- a/src/components/DeviceList.tsx +++ b/src/components/DeviceList.tsx @@ -1,9 +1,18 @@ -import { useState, useEffect } from "react"; +interface DeviceInfo { + name: string; + host: string; + addr: string; + port: number; + platform: string; + device_model: string; + max_width: number; + max_height: number; +} interface DeviceListProps { - devices: string[]; - selected: string | null; - onSelect: (addr: string) => void; + devices: DeviceInfo[]; + selected: DeviceInfo | null; + onSelect: (device: DeviceInfo) => void; onRefresh: () => void; scanning: boolean; } @@ -25,17 +34,27 @@ export function DeviceList({ devices, selected, onSelect, onRefresh, scanning }: {scanning &&

Scanning for AirDisplay devices...

}
    - {devices.map((addr) => ( -
  • onSelect(addr)} - > - 📱 - {addr} - {selected === addr && } -
  • - ))} + {devices.map((device) => { + const key = `${device.addr}:${device.port}`; + const isSelected = selected !== null && + selected.addr === device.addr && + selected.port === device.port; + return ( +
  • onSelect(device)} + > + {device.platform === "android" ? "📱" : "🖥️"} +
    + {device.name} + {device.addr}:{device.port} + {device.max_width}×{device.max_height} +
    + {isSelected && } +
  • + ); + })}
);