commit d9996bb42188de0daf8241386e9087dd3d5ba543 Author: yuqianhe Date: Sat May 30 07:57:32 2026 +0000 Initial scaffold: Rust+Tauri+React project structure for AirDisplay Client diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6fa63a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Rust +target/ +*.rs.bk + +# Node +node_modules/ +dist/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d47a29 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# AirDisplay Client + +Cross-platform desktop screen mirroring client for [AirDisplay](https://git.privserv.eu.org/yuqianhe/airdisplay) Android app. + +## Overview + +AirDisplay Client streams your PC screen to an Android device using the **AirDisplay Protocol (ADP)**, a lightweight custom protocol over TCP. It supports Windows, macOS, and Linux. + +## Architecture + +``` +┌─────────────────────┐ ADP (TCP:7935) ┌──────────────────┐ +│ AirDisplay Client │ ──────────────────────────► │ AirDisplay App │ +│ (Desktop: Rust+ │ mDNS discovery │ (Android) │ +│ Tauri+React) │ ◄────────────────────────── │ │ +└─────────────────────┘ └──────────────────┘ +``` + +### Technology Stack + +| Layer | Technology | +|---|---| +| **Desktop Framework** | Tauri v2 (Rust) | +| **Frontend UI** | React + TypeScript + Vite | +| **Screen Capture** | DXGI (Win) / CGDisplay (macOS) / PipeWire (Linux) | +| **Video Encoding** | x264 software (ffmpeg-next) / HW encoders (NVENC, QSV, VideoToolbox) | +| **Protocol** | ADP v1 (8-byte frame header + H.264 NAL over TCP) | +| **Discovery** | mDNS (`_airdisplay._tcp`) via mdns-sd | + +## Build + +### Prerequisites + +- Rust 1.75+ +- Node.js 18+ +- Platform-specific system deps (see [Tauri docs](https://v2.tauri.app/start/prerequisites/)) + +### Build Steps + +```bash +# Install dependencies +npm install + +# Development mode +npm run tauri dev + +# Production build +npm run tauri build +``` + +## Protocol Support + +| Protocol | Status | Description | +|---|---|---| +| ADP v1 | ✅ Planned | Custom lightweight frame protocol | +| Miracast | ❌ Not supported | Use Android app's built-in Miracast support | +| AirPlay | ❌ Not supported | Use macOS native screen mirroring to Android | + +## License + +MIT diff --git a/index.html b/index.html new file mode 100644 index 0000000..772dc78 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + AirDisplay Client + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..cb5e461 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "airdisplay-client", + "private": true, + "version": "0.1.0", + "description": "AirDisplay desktop client - screen mirroring to Android via ADP protocol", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@tauri-apps/api": "^2.0.0", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.1.0" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..0c41caf --- /dev/null +++ b/src-tauri/Cargo.toml @@ -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 diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..b82fd8f --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,9 @@ +{ + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "shell:allow-open" + ] +} diff --git a/src-tauri/src/adp/client.rs b/src-tauri/src/adp/client.rs new file mode 100644 index 0000000..ed6be74 --- /dev/null +++ b/src-tauri/src/adp/client.rs @@ -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, + 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() + } +} diff --git a/src-tauri/src/adp/frame.rs b/src-tauri/src/adp/frame.rs new file mode 100644 index 0000000..15be5aa --- /dev/null +++ b/src-tauri/src/adp/frame.rs @@ -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 { + 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 { + 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]]), + }) + } +} diff --git a/src-tauri/src/adp/handshake.rs b/src-tauri/src/adp/handshake.rs new file mode 100644 index 0000000..9367a0f --- /dev/null +++ b/src-tauri/src/adp/handshake.rs @@ -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, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CapabilityResponse { + pub protocol_version: u8, + pub device_name: String, + pub supported_codecs: Vec, + pub max_width: u32, + pub max_height: u32, + pub max_fps: u32, +} + +pub struct Handshaker; + +impl Handshaker { + pub fn new() -> Self { + Self + } +} diff --git a/src-tauri/src/adp/mod.rs b/src-tauri/src/adp/mod.rs new file mode 100644 index 0000000..7ef314b --- /dev/null +++ b/src-tauri/src/adp/mod.rs @@ -0,0 +1,4 @@ +//! ADP protocol client +pub mod client; +pub mod frame; +pub mod handshake; diff --git a/src-tauri/src/capture/mod.rs b/src-tauri/src/capture/mod.rs new file mode 100644 index 0000000..a689960 --- /dev/null +++ b/src-tauri/src/capture/mod.rs @@ -0,0 +1,2 @@ +//! Screen capture (platform specific) +pub mod platform; diff --git a/src-tauri/src/capture/platform.rs b/src-tauri/src/capture/platform.rs new file mode 100644 index 0000000..f71e27e --- /dev/null +++ b/src-tauri/src/capture/platform.rs @@ -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, + pub width: u32, + pub height: u32, + pub stride: u32, +} + +pub trait Capturer { + fn capture_frame(&mut self) -> anyhow::Result; +} diff --git a/src-tauri/src/encoder/mod.rs b/src-tauri/src/encoder/mod.rs new file mode 100644 index 0000000..b7c7082 --- /dev/null +++ b/src-tauri/src/encoder/mod.rs @@ -0,0 +1,2 @@ +mod soft; +pub use soft::SoftwareEncoder; diff --git a/src-tauri/src/encoder/soft.rs b/src-tauri/src/encoder/soft.rs new file mode 100644 index 0000000..8c72d21 --- /dev/null +++ b/src-tauri/src/encoder/soft.rs @@ -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>> { + // TODO: implement using ffmpeg-next + Ok(Vec::new()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..e4f5d89 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -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"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..e978842 --- /dev/null +++ b/src-tauri/src/main.rs @@ -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() +} diff --git a/src-tauri/src/mdns/discovery.rs b/src-tauri/src/mdns/discovery.rs new file mode 100644 index 0000000..08b0373 --- /dev/null +++ b/src-tauri/src/mdns/discovery.rs @@ -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, +} + +impl MdnsDiscovery { + pub fn new() -> Result { + let daemon = ServiceDaemon::new()?; + Ok(Self { + daemon: Arc::new(daemon), + }) + } + + /// Start browsing for AirDisplay devices + pub fn browse(&self) -> Result> { + 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 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(), + } + } +} diff --git a/src-tauri/src/mdns/mod.rs b/src-tauri/src/mdns/mod.rs new file mode 100644 index 0000000..714a8df --- /dev/null +++ b/src-tauri/src/mdns/mod.rs @@ -0,0 +1,2 @@ +//! mDNS service discovery +pub mod discovery; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..7176193 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -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" + ] + } +} diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..10ddc15 --- /dev/null +++ b/src/App.css @@ -0,0 +1,70 @@ +:root { + font-family: Inter, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 400; + color: #0f0f0f; + background-color: #f6f6f6; +} + +.container { + margin: 0 auto; + max-width: 600px; + padding: 2rem; + text-align: center; +} + +h1 { + font-size: 2rem; + margin-bottom: 0.25rem; +} + +.subtitle { + color: #666; + margin-top: 0; + margin-bottom: 2rem; +} + +.card { + background: white; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.card input { + padding: 0.5rem 1rem; + border: 1px solid #ccc; + border-radius: 6px; + margin-right: 0.5rem; +} + +.card button { + padding: 0.5rem 1.5rem; + border: none; + border-radius: 6px; + background: #396cd8; + color: white; + cursor: pointer; +} + +.card button:hover { + background: #2a5fc1; +} + +.info { + margin-top: 0.75rem; + color: #333; +} + +.status-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 0.5rem 1rem; + background: #e8e8e8; + text-align: center; + font-size: 0.875rem; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..793cc8c --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; + +function App() { + const [version, setVersion] = useState(""); + const [greeting, setGreeting] = useState(""); + const [name, setName] = useState(""); + + const getVersion = async () => { + const v = await (window as any).__TAURI_INTERNALS__.invoke("get_version"); + setVersion(v); + }; + + const greet = async () => { + const msg = await (window as any).__TAURI_INTERNALS__.invoke("greet", { + name: name || "AirDisplay", + }); + setGreeting(msg); + }; + + return ( +
+

AirDisplay Client

+

Screen mirroring to Android via ADP protocol

+ +
+ + {version &&

Version: {version}

} +
+ +
+ setName(e.target.value)} + /> + + {greeting &&

{greeting}

} +
+ +
+

Status: Disconnected

+
+
+ ); +} + +export default App; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..49d3b7a --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./App.css"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..4dd17ff --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +const host = process.env.TAURI_DEV_HOST; + +export default defineConfig(async () => ({ + plugins: [react()], + clearScreen: false, + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { protocol: "ws", host, port: 1421 } + : undefined, + watch: { + ignored: ["**/src-tauri/**"], + }, + }, +}));