Initial scaffold: Rust+Tauri+React project structure for AirDisplay Client

This commit is contained in:
yuqianhe
2026-05-30 07:57:32 +00:00
commit d9996bb421
26 changed files with 736 additions and 0 deletions

47
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,47 @@
[package]
name = "airdisplay-client"
version = "0.1.0"
description = "AirDisplay desktop client - screen mirroring to Android"
authors = ["yuqianhe"]
edition = "2021"
[lib]
name = "airdisplay_client_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[[bin]]
name = "airdisplay-client"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1"
thiserror = "1"
# ADP protocol
bytes = "1"
# mDNS discovery
mdns-sd = "0.12"
# Video encoding (software fallback)
ffmpeg-next = "7.0"
# Screen capture (platform specific)
[target.'cfg(target_os = "windows")'.dependencies]
captrs = "0.3"
[target.'cfg(target_os = "macos")'.dependencies]
core-graphics = "0.24"
[target.'cfg(target_os = "linux")'.dependencies]
# PipeWire for Wayland, x11 for X11

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,9 @@
{
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open"
]
}

View File

@@ -0,0 +1,52 @@
//! ADP TCP client
//!
//! Connects to Android ADP server, performs handshake,
//! sends H.264 NAL frames via the frame protocol.
use anyhow::Result;
use tokio::net::TcpStream;
use tokio::io::{AsyncWriteExt, AsyncReadExt};
use crate::adp::frame::{FrameHeader, FrameType, ADP_DEFAULT_PORT};
pub struct AdpClient {
stream: Option<TcpStream>,
sequence: u32,
server_addr: String,
}
impl AdpClient {
pub fn new(addr: String) -> Self {
Self {
stream: None,
sequence: 0,
server_addr: format!("{}:{}", addr, ADP_DEFAULT_PORT),
}
}
pub async fn connect(&mut self) -> Result<()> {
let stream = TcpStream::connect(&self.server_addr).await?;
self.stream = Some(stream);
self.sequence = 0;
Ok(())
}
pub async fn send_frame(&mut self, frame_type: FrameType, payload: &[u8]) -> Result<()> {
let stream = self.stream.as_mut().unwrap();
let header = FrameHeader::new(frame_type, self.sequence, payload.len() as u32);
self.sequence += 1;
stream.write_all(&header.to_bytes()).await?;
stream.write_all(payload).await?;
Ok(())
}
pub async fn disconnect(&mut self) -> Result<()> {
if let Some(mut stream) = self.stream.take() {
stream.shutdown().await?;
}
Ok(())
}
pub fn is_connected(&self) -> bool {
self.stream.is_some()
}
}

View File

@@ -0,0 +1,88 @@
//! ADP frame protocol definition
//!
//! Frame format (8-byte header):
//! ```text
//! 0 1 2 3
//! 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//! | Magic (0xAD) | Version | Type | Flags |
//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//! | Sequence Number | Payload Length |
//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//! ```
//!
//! Types:
//! - 0x01: Capability Request/Response (handshake)
//! - 0x02: Video Frame (H.264 NAL unit)
//! - 0x03: Keepalive
//! - 0x04: UIBC Event (touch/keyboard from phone to PC)
//! - 0x05: Resolution Change Notification
pub const ADP_MAGIC: u16 = 0x0BAD;
pub const ADP_VERSION: u8 = 1;
pub const ADP_DEFAULT_PORT: u16 = 7935;
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameType {
Capability = 0x01,
VideoFrame = 0x02,
Keepalive = 0x03,
UibcEvent = 0x04,
ResolutionChange = 0x05,
}
#[repr(C)]
#[derive(Debug, Clone)]
pub struct FrameHeader {
pub magic: u16, // 0x0BAD
pub version: u8, // 1
pub frame_type: u8, // FrameType
pub flags: u8, // reserved
pub sequence: u32, // sequence number
pub payload_len: u32, // payload length
}
impl FrameHeader {
pub const SIZE: usize = 12;
pub fn new(frame_type: FrameType, sequence: u32, payload_len: u32) -> Self {
Self {
magic: ADP_MAGIC,
version: ADP_VERSION,
frame_type: frame_type as u8,
flags: 0,
sequence,
payload_len,
}
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(Self::SIZE);
buf.extend_from_slice(&self.magic.to_be_bytes());
buf.push(self.version);
buf.push(self.frame_type);
buf.push(self.flags);
buf.extend_from_slice(&self.sequence.to_be_bytes());
buf.extend_from_slice(&self.payload_len.to_be_bytes());
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < Self::SIZE {
return None;
}
let magic = u16::from_be_bytes([data[0], data[1]]);
if magic != ADP_MAGIC {
return None;
}
Some(Self {
magic,
version: data[2],
frame_type: data[3],
flags: data[4],
sequence: u32::from_be_bytes([data[5], data[6], data[7], data[8]]),
payload_len: u32::from_be_bytes([data[9], data[10], data[11], data[12]]),
})
}
}

View File

@@ -0,0 +1,34 @@
//! ADP handshake (capability negotiation)
//!
//! Flow:
//! 1. Client connects → sends Capability Request
//! 2. Server responds with Capability Response (codecs, resolutions, etc.)
//! 3. Client selects preferred settings and starts streaming
use anyhow::Result;
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct CapabilityRequest {
pub protocol_version: u8,
pub client_name: String,
pub codecs: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CapabilityResponse {
pub protocol_version: u8,
pub device_name: String,
pub supported_codecs: Vec<String>,
pub max_width: u32,
pub max_height: u32,
pub max_fps: u32,
}
pub struct Handshaker;
impl Handshaker {
pub fn new() -> Self {
Self
}
}

4
src-tauri/src/adp/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
//! ADP protocol client
pub mod client;
pub mod frame;
pub mod handshake;

View File

@@ -0,0 +1,2 @@
//! Screen capture (platform specific)
pub mod platform;

View File

@@ -0,0 +1,21 @@
//! Platform-specific screen capture
//!
//! Each platform module provides a `Capturer` that produces raw RGBA frames.
#[cfg(target_os = "windows")]
pub mod windows;
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(target_os = "linux")]
pub mod linux;
pub struct CapturedFrame {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
pub stride: u32,
}
pub trait Capturer {
fn capture_frame(&mut self) -> anyhow::Result<CapturedFrame>;
}

View File

@@ -0,0 +1,2 @@
mod soft;
pub use soft::SoftwareEncoder;

View File

@@ -0,0 +1,21 @@
//! Software H.264 encoder using ffmpeg-next (libx264)
use anyhow::Result;
pub struct SoftwareEncoder {
width: u32,
height: u32,
fps: u32,
}
impl SoftwareEncoder {
pub fn new(width: u32, height: u32, fps: u32) -> Self {
Self { width, height, fps }
}
/// Encode a raw RGBA frame to H.264 NAL unit(s)
pub fn encode(&mut self, _frame: &[u8]) -> Result<Vec<Vec<u8>>> {
// TODO: implement using ffmpeg-next
Ok(Vec::new())
}
}

31
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,31 @@
use tauri::Manager;
mod adp;
mod capture;
mod encoder;
mod mdns;
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! AirDisplay Client is ready.", name)
}
#[tauri::command]
fn get_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
pub fn run() {
tracing_subscriber::init();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![greet, get_version])
.setup(|app| {
let window = app.get_webview_window("main").unwrap();
window.set_title("AirDisplay Client").ok();
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
airdisplay_client_lib::run()
}

View File

@@ -0,0 +1,81 @@
//! mDNS device discovery
//!
//! Discovers Android AirDisplay devices on the local network
//! using the `_airdisplay._tcp` service type.
use anyhow::Result;
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use std::sync::Arc;
use tokio::sync::mpsc;
const SERVICE_TYPE: &str = "_airdisplay._tcp.local.";
pub struct MdnsDiscovery {
daemon: Arc<ServiceDaemon>,
}
impl MdnsDiscovery {
pub fn new() -> Result<Self> {
let daemon = ServiceDaemon::new()?;
Ok(Self {
daemon: Arc::new(daemon),
})
}
/// Start browsing for AirDisplay devices
pub fn browse(&self) -> Result<mpsc::Receiver<DeviceEvent>> {
let (tx, rx) = mpsc::channel(32);
let receiver = self.daemon.browse(SERVICE_TYPE)?;
let tx_clone = tx;
std::thread::spawn(move || {
for event in receiver {
let device_event = match event {
ServiceEvent::ServiceResolved(info) => {
Some(DeviceEvent::Discovered(DeviceInfo::from(info)))
}
ServiceEvent::ServiceRemoved(_, name) => {
Some(DeviceEvent::Lost(name.to_string()))
}
_ => None,
};
if let Some(e) = device_event {
if tx_clone.blocking_send(e).is_err() {
break;
}
}
}
});
Ok(rx)
}
}
#[derive(Debug, Clone)]
pub enum DeviceEvent {
Discovered(DeviceInfo),
Lost(String),
}
#[derive(Debug, Clone)]
pub struct DeviceInfo {
pub name: String,
pub addr: String,
pub port: u16,
pub host: String,
}
impl From<ServiceInfo> for DeviceInfo {
fn from(info: ServiceInfo) -> Self {
let addr = info.get_addresses().iter()
.next()
.map(|a| a.to_string())
.unwrap_or_default();
Self {
name: info.get_fullname().to_string(),
addr,
port: info.get_port(),
host: info.get_hostname().to_string(),
}
}
}

View File

@@ -0,0 +1,2 @@
//! mDNS service discovery
pub mod discovery;

37
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,37 @@
{
"$schema": "https://raw.githubusercontent.com/nicoverbruggen/tauri-v2-schema/main/schema.json",
"productName": "AirDisplay Client",
"version": "0.1.0",
"identifier": "com.airdisplay.client",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "AirDisplay Client",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}