airhand README

The project's README from github.com/sqlpxc/airhand. As one markdown file: README.md.

Airhand

Airhand

High-performance remote desktop over QUIC · Rust + Tauri + React

English · 简体中文


Airhand is a single program that is both the host (the computer being controlled) and the controller (the computer you control it from). It runs on Windows, macOS (13 or later) and Linux (X11 desktops). A small relay server, deployed with one Docker Compose command, handles signaling and forwards traffic. The controller can also be deployed with Docker Compose as a web console that you use from a browser.

Status: under active development. The protocol still changes between versions, so the clients and the server must be built from the same version (a mismatch is detected and reported, see Protocol versions).

Features

Contents

Quick start

  1. Deploy the server on a machine both computers can reach (only UDP 4433 needs to be open):

    docker compose up -d
    docker compose logs airhand | grep fingerprint   # note the certificate SHA-256 fingerprint
    
  2. Build the client with the server address and fingerprint compiled in (requirements and the macOS / Linux steps in Building the client):

    cd client
    npm install
    $env:AIRHAND_SERVER    = "relay.example.com:4433"
    $env:AIRHAND_SERVER_FP = "<fingerprint from step 1>"
    npm run tauri build -- --no-bundle
    # → client/src-tauri/target/release/airhand.exe (single file, ~9 MB)
    
  3. Run it on both computers. On the remote one, right-click the tray icon to see its 6-digit control code. On yours, choose Switch to controller in the tray menu and enter the code.

    If you'd rather not install the client on the computer you control from, use the web console instead: cd web && AIRHAND_SERVER=relay.example.com:4433 AIRHAND_SERVER_FP=<fingerprint> docker compose up -d, then open it in a browser.

Usage

Host (default)

Double-click to run. A tray icon appears in the bottom-right corner (on macOS in the menu bar at the top of the screen, on Linux in the top bar / panel status area). Grey means not connected to the server, coloured means connected. The right-click menu (a plain click on macOS) contains:

After a network drop the host reconnects automatically with exponential backoff and gets its old control code back, even after a server restart. A code is reserved for its host for 10 minutes after the host goes offline.

On Linux the tray icon uses AppIndicator. It works out of the box on Ubuntu, KDE, Xfce and similar; stock GNOME needs the "AppIndicator and KStatusNotifierItem Support" extension. Without an AppIndicator library the program still runs as a host and prints the control code to the log (stderr).

On macOS the host needs two system permissions: Screen & System Audio Recording (to capture the screen and sound) and Accessibility (to control the keyboard and mouse). macOS asks on the first start; while one is missing, the menu says so, and clicking that item opens the matching page in System Settings → Privacy & Security. A connected controller is told which permission is missing too (in the controller's own language), and the message goes away once it is granted. After granting Screen & System Audio Recording, reopen Airhand. Airhand normally lives in the menu bar only and shows up in the Dock while the console is open.

Controller

Tray → Switch to controller opens the console. Closing the console window switches back to host mode.

Quality panel: presets from Data saver (720p / 24 fps / 1.2 Mbps) through Balanced (1080p / 30 fps / 4 Mbps), Smooth (1080p / 60 fps / 8 Mbps) and HD (1440p / 60 fps / 16 Mbps) to Original (native resolution / 60 fps / 30 Mbps), or set the resolution cap, frame rate (15–120), bitrate cap (0.5–50 Mbps), codec and adaptive bitrate yourself. The console only offers codecs this computer can hardware-decode; if the host has no matching hardware encoder it falls back automatically, and the toolbar shows the encoder actually in use.

Language

The console and tray menu are available in English and Simplified Chinese. By default the client follows the system display language: Chinese if it is Chinese, English otherwise. Switch any time from the tray menu (Language / 语言) or the 中文 / EN switch at the bottom-left of the console; like every other setting it isn't saved.

Notices and errors from the other computer or the server are sent as message types, not text, so they always appear in your language. Logs (client and server) are always in English.

Server configuration

The server listens on UDP 4433. On first start it generates a self-signed certificate in the data/ directory next to docker-compose.yml. Keep that directory: clients recognise the server by the certificate's fingerprint.

Environment variables (see docker-compose.yml):

Variable Default Description
AIRHAND_LISTEN 0.0.0.0:4433 Listen address (UDP)
AIRHAND_CERT / AIRHAND_KEY /data/cert.pem / /data/key.pem Certificate paths; generated automatically if missing
AIRHAND_CERT_NAMES airhand,localhost SANs written into an auto-generated certificate
AIRHAND_MAX_SESSIONS 4 Max simultaneous controllers per host
RUST_LOG info Log level

The image is based on distroless, runs as a non-root user, and is about 28 MB.

Web console (Docker)

The controller can also run in a container: start it with Docker Compose and open one port in a browser to get the console, with nothing to install or build on the computer you control from. It's the same console as the desktop one (several sessions, multi-screen overview, quality, sound, clipboard…); the computers being controlled still run the Airhand client.

cd web
AIRHAND_SERVER=relay.example.com:4433 AIRHAND_SERVER_FP=<relay certificate fingerprint> docker compose up -d
# or: cp .env.example .env, edit it, then just docker compose up -d

Then open it in a browser:

The same port speaks both HTTP and HTTPS. Browsers only offer WebCodecs video decoding on secure pages (HTTPS or localhost), so use https:// from other computers (opened over http://, the page links you to the https address). The certificate is a self-signed one generated on first start and kept in a data volume; the first time, the browser warns about it, and you continue to the site. Needs Chrome / Edge, Safari 16.4+ or Firefox 130+.

Environment variables (see web/docker-compose.yml and web/.env.example):

Variable Default Description
AIRHAND_SERVER (required) Relay server address host:port
AIRHAND_SERVER_FP empty Relay certificate fingerprint. Empty means the certificate is not verified; for development only (the console shows "Not pinned")
AIRHAND_NAME Airhand Web The name shown in the host's tray under who is controlling it
AIRHAND_WEB_PASSWORD empty Access password: the browser shows a login prompt, any user name works. Empty means anyone who can reach the port can use the console (connecting to a computer still needs its control code)
AIRHAND_WEB_PORT 8080 Port published on the Docker host
AIRHAND_WEB_CERT_NAMES localhost Names / IPs written into the generated HTTPS certificate (comma-separated). List the address you actually use if you want the OS to trust the certificate
AIRHAND_WEB_CERT / AIRHAND_WEB_KEY /data/cert.pem / /data/key.pem HTTPS certificate paths; mount your own certificate here to use it
RUST_LOG info Log level

Building the client

Requirements: Rust (stable), Node.js 20+, CMake (to build libopus).

The server address and certificate fingerprint are compiled into the program: set AIRHAND_SERVER and AIRHAND_SERVER_FP at build time (as in Quick start), or change the defaults in client/src-tauri/src/config.rs. Without AIRHAND_SERVER the client connects to 127.0.0.1:4433. A malformed fingerprint (it must be 64 hex digits; : and spaces are allowed) fails the build.

When AIRHAND_SERVER_FP is empty the server certificate is not verified, which leaves you open to man-in-the-middle attacks. Use that for development only; the console then shows "Not pinned" at the top left.

Windows: npm run tauri build -- --no-bundle produces client/src-tauri/target/release/airhand.exe. Drop --no-bundle to also get an NSIS installer.

Linux (set the variables with export AIRHAND_SERVER=...):

cd client && npm install
npm run tauri build
# → client/src-tauri/target/release/airhand (single file, ~10 MB)
#   client/src-tauri/target/release/bundle/deb/Airhand_<version>_amd64.deb (pulls in the tray, audio and GStreamer dependencies)

macOS (set the variables with export as well):

cd client && npm install
npm run tauri build
# → client/src-tauri/target/release/bundle/macos/Airhand.app (~8 MB)
#   client/src-tauri/target/release/bundle/dmg/Airhand_<version>_<arch>.dmg

How it works

Architecture

 Host (Windows; others: see table)       airhand-server (Docker)                  Controller (Tauri / web)
┌───────────────────────────┐            ┌───────────────────────────┐            ┌───────────────────────────┐
│ DXGI capture (GPU)        │            │ Control-code registry     │            │ WebCodecs HW decode       │
│ SIMD scale + NV12 convert │  QUIC/TLS  │ Session pairing           │  QUIC/TLS  │ Low-latency Canvas        │
│ HW encode AV1/HEVC/H.264  │◀══════════▶│ Zero-copy byte relay      │◀══════════▶│ Input capture + cursor    │
│ SendInput injection       │   UDP 4433 │ Brute-force rate limiting │   UDP 4433 │ Multi-session / overview  │
│ WASAPI loopback + Opus    │            │                           │            │ Jitter-buffered audio     │
└───────────────────────────┘            └───────────────────────────┘            └───────────────────────────┘

The first bidirectional stream on every QUIC connection is the control stream (signaling). For each remote-control session both sides open another bidirectional stream; the server pairs them by session ID and from then on only forwards bytes, it never parses the video. Every message is a u32 length prefix followed by a postcard-encoded payload. When a controller reconnects it presents the resume token it got when it connected; if the host is offline the server remembers the request and sends HostOnline when the host is back (see "Resume tokens" in crates/airhand-proto/src/signal.rs).

The web console splits the controller on the right in two: the QUIC connection and session handling run in airhand-web (the container), while decoding, rendering and input capture stay in the browser, with one WebSocket in between. Packets are forwarded to the browser unchanged (with a 4-byte session number in front), and the browser acknowledges the frames.

Directory Contents
crates/airhand-proto Protocol: signaling, session messages, framing
crates/airhand-link The client side of the relay link: QUIC, certificate pinning, reconnects, UI-independent controller sessions, Problem. Shared by the desktop client and the web console
server/ Relay server (quinn + rustls) and its Dockerfile
web/ Web console service (axum: static files + WebSocket, HTTP and HTTPS on one port), its Dockerfile and docker-compose.yml. A separate Cargo workspace, like client/src-tauri/
client/src-tauri/ Desktop client, Rust side: host host/, tray, console window and commands
client/src/ Console UI (React 19 + TypeScript, no UI library), shared by the desktop and web consoles, which differ only in client/src/lib/backend/; strings in client/src/locales/
assets/icon.svg Application icon source

How each host stage is implemented on the three platforms:

Stage Windows macOS Linux (X11)
Screen capture DXGI Desktop Duplication ScreenCaptureKit MIT-SHM shared memory + DAMAGE change notifications; RandR for multiple monitors
Cursor shape DXGI NSCursor XFIXES
Video encoding Media Foundation hardware encoders, OpenH264 fallback VideoToolbox hardware encoders (H.264 / HEVC), OpenH264 fallback OpenH264
Input injection SendInput (scan codes) CGEvent (virtual key codes) XTEST (evdev physical key codes)
Audio capture WASAPI loopback ScreenCaptureKit (system mix) PulseAudio / PipeWire output monitor
Clipboard Change detection via the clipboard sequence number NSPasteboard change count XFIXES selection-owner change notifications
Console UI WebView2 WKWebView WebKitGTK (WebCodecs decoding via GStreamer)

Video

Stage How it works
Capture Frames are only produced when the screen changes (DXGI on Windows, ScreenCaptureKit on macOS, DAMAGE on X11), so an idle desktop uses almost no bandwidth
Preprocessing fast_image_resize (SIMD) for scaling, yuvutils-rs (AVX2) for BGRA→NV12/I420; all buffers are reused
Encoding Hardware first: Media Foundation hardware encoders (NVENC / Quick Sync / AMF) for AV1 / HEVC / H.264, low-latency mode, no B-frames, very long GOP; on macOS VideoToolbox (H.264 / HEVC, low-latency rate control for H.264). Without one it falls back to OpenH264 in screen-content mode
Rate control The controller ACKs every frame. The host caps the number of in-flight frames (≈ RTT × fps + 2); on a slow network it waits and skips to the newest frame instead of building up lag. Adaptive bitrate steps down under congestion and recovers gradually
Transport QUIC with BBR congestion control (steadier than Cubic on lossy links such as Wi-Fi or cross-border routes). Control messages, audio and video are queued separately, so pings and mouse events never wait behind a large frame
Decoding The controller decodes on the GPU with WebCodecs (optimizeForLatency) and paints straight into a desynchronized Canvas
Cursor The remote cursor's shape is mirrored to the local cursor and movement is handled locally, so there is no perceptible cursor lag
Background sessions Sessions that aren't on screen drop to 5 fps

Measured locally (RTX GPU, 4K display output at 1080p): hardware AV1 encodes a frame in about 7 ms, H.264 in about 9 ms; OpenH264 software encoding takes about 40 ms.

Audio

The host's system sound (not the microphone) is streamed to the controller:

Stage How it works
Capture Loopback capture of the default output device, converted to 48 kHz stereo; follows the default device when it changes (e.g. you plug in headphones)
Encoding Opus at 96 kbps, 20 ms per packet; no packets during silence
Transport Same stream as the video, queued after control messages and before video; when the queue is full packets are dropped instead of building up lag
Playback WebCodecs decoding → AudioWorklet adaptive jitter buffer: about 40 ms on a stable network, growing under jitter and shrinking back once stable. A ±1–2% speed adjustment cancels the clock drift between the two sound cards, and a large backlog is skipped

Only the session you are looking at plays audio (none do in the multi-screen overview); the other hosts are told to stop capturing.

When the host can't capture sound (no audio output device, or no PulseAudio / PipeWire running on Linux, for example), the controller shows a message, the speaker in the toolbar gets a yellow dot and the sound menu gives the reason. The host retries every 2 seconds, and the message goes away once it recovers.

A/V sync (optional, off by default)

Audio goes through a jitter buffer while video is shown as soon as it arrives, so by default audio lags video slightly: about 70 ms in our measurements (same machine, local relay, 4K output at 1080p; 49–78 ms per run). That's barely noticeable while working, but may be when watching video.

With A/V sync turned on (speaker menu in the toolbar):

The delay from painting to the Canvas to the pixels lighting up is estimated at 8 ms (not measured with a camera).

Zero files

Security

Known limitations

Development

Tests

# repository root: protocol + link library + server
cargo test

# web/: web console (WebSocket protocol, password, HTTPS certificate)
cargo test

# client/: UI (jitter buffer and A/V sync simulations, H.264 conversion, UI strings)
npm test

# client/src-tauri/: client Rust (encoders incl. hardware probing, Opus packing, format conversion…)
cargo test --lib
# audio loopback capture (needs an audio output device; plays a 1.5 s test tone at -60 dBFS, barely audible)
cargo test --lib loopback_captures_tone -- --ignored --nocapture

On macOS the screen / audio capture tests (sck_capture, loopback_captures_tone, video and audio in the end-to-end test) and the input injection test (mac_move_to_center) need the Screen & System Audio Recording and Accessibility permissions for the terminal that runs them (System Settings → Privacy & Security). Without them sck_capture, loopback_captures_tone and mac_move_to_center skip themselves; the end-to-end test checks that the host reports the missing permission (rather than a capture failure) and then skips the video and audio checks (clipboard, ping, disconnect and the rest are still checked).

End to end (a real server, a real host with screen capture and encoding, and a protocol-level controller):

# repository root: start a server without certificate pinning
cargo build -p airhand-server
AIRHAND_CERT=target/e2e/cert.pem AIRHAND_KEY=target/e2e/key.pem ./target/debug/airhand-server &

# client/src-tauri/
cargo test --lib e2e_roundtrip -- --ignored --nocapture   # video / codec switching / clipboard / audio / ping / disconnect
cargo test --lib e2e_reconnect -- --ignored --nocapture   # getting the code back after a server restart, changing the code

Debugging tools

# client/: debug build that starts straight in controller mode; AIRHAND_LOG sets the log level
AIRHAND_DEV_CONTROLLER=1 AIRHAND_LOG=debug npm run tauri dev
# client/: A/V sync measurement (the host plays a white flash + short beep; usage at the top of the script)
node scripts/measure-avsync.mjs <control-code>

# client/src-tauri/: headless host, for testing the controller on the same machine
# (mute the controller, otherwise the played-back audio is captured again and echoes)
cargo run --release --example headless_host
# client/src-tauri/: encoder benchmark (captures the real screen and compares the encoders)
cargo run --release --example encode_bench

Use release builds when judging smoothness or A/V sync: debug builds scale very slowly (4K → 1080p takes about 0.5 s per frame).

Web console

# build the UI, then start the service in web/ (connects to a server on 127.0.0.1:4433, serves ../client/dist); open http://localhost:8080
(cd client && npm run build)
cd web && cargo run
# while working on the UI: npm run dev in client/ and open http://localhost:1420 (/ws is proxied to airhand-web on port 8080)

The WebSocket protocol between the browser and the service is described at the top of web/src/console.rs.

Linux / WSL

bash client/scripts/linux-test.sh runs all of the tests above (unit, X11 capture and input injection, audio loopback, end to end, frontend). The X11 tests open fullscreen windows and inject input, so the script starts its own Xvfb screen and leaves your desktop alone; audio tests are skipped without PulseAudio / PipeWire. It needs sudo apt install xvfb x11-apps.

On Windows you can use WSL2 (tested on Ubuntu 24.04; WSLg ships with PulseAudio):

# Copy the repository to WSL's own disk first (building under /mnt/c or /mnt/d is much slower)
rsync -a --exclude target --exclude node_modules /mnt/d/work/Projects/airhand/ ~/airhand/
cd ~/airhand && bash client/scripts/linux-test.sh

Conventions

Protocol versions

Client and server compare PROTOCOL_VERSION (crates/airhand-proto/src/lib.rs) when they connect. On a mismatch the server replies Incompatible and the client tells the user whether to update Airhand or the server, then retries once a minute. Bump the version for any incompatible protocol change. The handshake itself (ClientMsg::Hello, ServerMsg::Welcome / Incompatible, the ALPN) must never change, otherwise different versions can't even tell each other that they differ. The version_handshake_wire_is_stable test guards this.

Messages are types, not text

Anything the user may see that crosses the network or comes from the Rust side is an enum (ConnectError, Notice, ByeReason, Problem…). The UI turns it into text in the user's language:

Icon

The source is assets/icon.svg. After editing it, run npx tauri icon ../assets/icon.svg -o src-tauri/icons in client/, then copy src-tauri/icons/32x32.png to src-tauri/icons/tray.png.

airhand (sqlpxc/airhand)

Searching Database…

What is this?Remote desktop software. One program acts as both the host (the computer being controlled) and the controller (the computer you control it from), and the two connect through a small relay server over QUIC.
Is it a single app or a server?Both. You run the desktop client on each computer, and you deploy a relay server with Docker Compose that handles signaling and forwards traffic between them.
How do I get it running?Deploy the relay server with Docker Compose on a machine both computers can reach. Then build the client with the server address and certificate fingerprint compiled in, run it on both computers, and enter the 6-digit control code shown on the remote one.
What do I need to build the client?Rust (stable), Node.js 20+ and CMake. On Windows you need Windows 10/11, on macOS 13+ with the Xcode Command Line Tools, and on Linux the Tauri dependencies, PulseAudio development files and GStreamer plugins.
What are the catches?The project is under active development and the protocol changes between versions, so the server and every client must be built from the same version. On stock GNOME the Linux tray icon needs the AppIndicator extension, and macOS needs Screen Recording and Accessibility permissions.
Which operating systems are supported?Windows, macOS 13 or later, and Linux with X11 desktops. Wayland isn't mentioned.
Can I run the relay on a VPS?Yes. The relay is a Docker Compose deployment on any machine both computers can reach, and only inbound UDP 4433 needs to be open.
Is the connection encrypted and protected?Yes. Traffic uses TLS 1.3 with certificate pinning, and control codes have brute-force protection.
Can I control a computer from a browser without installing anything?Yes. The controller can run as a web console in Docker Compose, and you open one port in a browser. Nothing needs to be installed on the computer you control from, though the computers being controlled still run the Airhand client.
What happens on the computer being controlled?After launch it shows only a tray icon, with no window and no WebView. After a network drop it reconnects automatically and keeps its old control code, even after a server restart.
What can I do once connected?You can control several computers at once, use a multi-screen overview, sync the clipboard both ways or type it as keystrokes, stream the host's system sound with optional A/V sync, and use adaptive bitrate for low latency.
What are similar projects?rustdesk-server, xpra, Cap, nyaterm, omnyssh, numa, buzz, Piik
Creator
sqlpxc
Size
1.1 MB
Stars
340
Updated
CommitNov 02
Age
3d
Commits · 90 Days