Initial scaffold: Rust+Tauri+React project structure for AirDisplay Client
This commit is contained in:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@@ -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
|
||||||
61
README.md
Normal file
61
README.md
Normal file
@@ -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
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AirDisplay Client</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src-tauri/Cargo.toml
Normal file
47
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
9
src-tauri/capabilities/default.json
Normal file
9
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"shell:allow-open"
|
||||||
|
]
|
||||||
|
}
|
||||||
52
src-tauri/src/adp/client.rs
Normal file
52
src-tauri/src/adp/client.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src-tauri/src/adp/frame.rs
Normal file
88
src-tauri/src/adp/frame.rs
Normal 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]]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src-tauri/src/adp/handshake.rs
Normal file
34
src-tauri/src/adp/handshake.rs
Normal 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
4
src-tauri/src/adp/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
//! ADP protocol client
|
||||||
|
pub mod client;
|
||||||
|
pub mod frame;
|
||||||
|
pub mod handshake;
|
||||||
2
src-tauri/src/capture/mod.rs
Normal file
2
src-tauri/src/capture/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
//! Screen capture (platform specific)
|
||||||
|
pub mod platform;
|
||||||
21
src-tauri/src/capture/platform.rs
Normal file
21
src-tauri/src/capture/platform.rs
Normal 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>;
|
||||||
|
}
|
||||||
2
src-tauri/src/encoder/mod.rs
Normal file
2
src-tauri/src/encoder/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
mod soft;
|
||||||
|
pub use soft::SoftwareEncoder;
|
||||||
21
src-tauri/src/encoder/soft.rs
Normal file
21
src-tauri/src/encoder/soft.rs
Normal 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
31
src-tauri/src/lib.rs
Normal 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
6
src-tauri/src/main.rs
Normal 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()
|
||||||
|
}
|
||||||
81
src-tauri/src/mdns/discovery.rs
Normal file
81
src-tauri/src/mdns/discovery.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src-tauri/src/mdns/mod.rs
Normal file
2
src-tauri/src/mdns/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
//! mDNS service discovery
|
||||||
|
pub mod discovery;
|
||||||
37
src-tauri/tauri.conf.json
Normal file
37
src-tauri/tauri.conf.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/App.css
Normal file
70
src/App.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
48
src/App.tsx
Normal file
48
src/App.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="container">
|
||||||
|
<h1>AirDisplay Client</h1>
|
||||||
|
<p className="subtitle">Screen mirroring to Android via ADP protocol</p>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<button onClick={getVersion}>Get Version</button>
|
||||||
|
{version && <p className="info">Version: {version}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button onClick={greet}>Greet</button>
|
||||||
|
{greeting && <p className="info">{greeting}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="status-bar">
|
||||||
|
<p>Status: Disconnected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -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" }]
|
||||||
|
}
|
||||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
20
vite.config.ts
Normal file
20
vite.config.ts
Normal file
@@ -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/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user