NetDesk 1.5.0 — source release

This commit is contained in:
NetDesk committed 2026-10-18 00:47:49 +00:00
commit 21dea33089
227 files changed
+80714

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
# build artifacts
dist/
build/
*.spec.bak
*.log
# runtime state / user data - NEVER commit (contains tokens & password hashes)
portable/
portable_test/
deploy_secrets.txt
server/*.db
server/files/
*.db
# python
__pycache__/
*.pyc
.venv/
venv/
# OS
Thumbs.db
.DS_Store
# Docker: local secrets, never commit
docker/.env
+19
View File
@@ -0,0 +1,19 @@
NetDesk — Source-Available License
Copyright (c) 2026 Akronlogic. All rights reserved.
You are granted a free, non-exclusive right to:
• download, read, and study this source code;
• build it and run your OWN self-hosted instance (personal or internal
business use), for any number of your own machines;
• modify it for your own self-hosting needs.
Conditions:
• This notice and the copyright above must be kept in all copies.
• You may NOT resell, re-brand, or offer this software (modified or not)
as a hosted/managed service to third parties without written permission
from Akronlogic.
• The software is provided "AS IS", without warranty of any kind. Akronlogic
is not liable for any damages arising from its use.
For commercial redistribution, OEM, or managed-hosting licensing, contact the
project owner via https://netdesk.space .
+45
View File
@@ -0,0 +1,45 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_submodules
_zc = collect_submodules('zeroconf') + collect_submodules('aioice') + collect_submodules('aiortc')
# dxcam (DXGI Desktop Duplication fast capture) + its comtypes dependency.
_dx = collect_submodules('dxcam') + collect_submodules('comtypes')
a = Analysis(
['host/netdesk_host.py'],
pathex=['host'],
binaries=[],
datas=[('host/netdesk_gui.html', '.')],
hiddenimports=['netdesk_gui', 'pyaudiowpatch', 'qrcode', 'ipaddress',
'pylibsrtp', 'pyee', 'ifaddr', 'dxcam', 'comtypes',
'google_crc32c', 'OpenSSL', 'dns'] + _zc + _dx,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='NetDeskHost',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['host/icon.ico'],
)
+38
View File
@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['server/netdesk_server.py'],
pathex=[],
binaries=[],
datas=[('server/web', 'web')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='NetDeskServer',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+279
View File
@@ -0,0 +1,279 @@
# NetDesk — your own self-hosted remote desktop
<p>
<a href="https://netdesk.space"><img alt="Website" src="https://img.shields.io/badge/website-netdesk.space-19c87e"></a>
<img alt="Version" src="https://img.shields.io/badge/version-1.5.0-blue">
<img alt="Platform" src="https://img.shields.io/badge/platform-Windows%20%7C%20Web%20%7C%20macOS%20(build)-lightgrey">
<img alt="E2E" src="https://img.shields.io/badge/encryption-ECDH%20P--256%20%2B%20AES--256--GCM-orange">
</p>
**Website & live demo:** [netdesk.space](https://netdesk.space) · **Download:** [Windows agent](https://netdesk.space/download) · **Pricing:** [free to self-host](https://netdesk.space/pricing)
A TeamViewer/RustDesk-style remote desktop system you fully own: a signaling/relay
server for your VPS, a **Windows service** on your powerful PC (reachable at boot,
at the logon screen, and across UAC prompts), and a **zero-install web viewer**
(Chrome/Edge) you can use from any laptop or phone while traveling.
```
┌──────────────────────────┐ WSS outbound only ┌──────────┐ WSS ┌──────────────┐
│ HOME PC (Windows service)│ ──────────────────► │ VPS │ ◄────── │ TRAVEL laptop│
│ broker (SYSTEM, boot) │ │ netdesk │ │ any browser │
│ └─ desktop agent │ ◄──── relayed ────► │ server │ │ (phone works)│
│ (console session) │ E2E-encrypted └──────────┘ └──────────────┘
└──────────────────────────┘
│
└──── optional direct mode: ws://home-ip:8901 (LAN / port-forward) ────┘
```
## Features
**Access & unattended operation**
- Boot-time Windows service (SYSTEM): no auto-logon needed, survives reboots
- Reachable at the **Windows logon screen** — view and type credentials to log in
- Input works across desktop switches (user desktop, logon screen, UAC prompts) via desktop-following injection
- Auto-reconnect to server forever (works behind any NAT, no port forwarding)
- Wake displays automatically when a viewer connects
**Video / audio**
- H.264 (x264 ultrafast/zerolatency), up to 1080p60 @ 20 Mbps, idle screens send nothing
- Hardware decode in the browser (WebCodecs) + HTML5 canvas
- Adaptive **Auto quality** mode (RTT/decoder-queue based preset stepping)
- System audio (WASAPI loopback → Opus) — bundled in the packaged exe; `pip install PyAudioWPatch` for source runs
**Full control**
- Mouse (all buttons, wheel), keyboard via SendInput scancodes, keyboard-lock for Alt+Tab/Win/Ctrl+W in fullscreen
- **Privacy mode** — turn the physical monitor off while you keep working
- **Lock PC on disconnect** (per-viewer checkbox)
- Process manager — list & kill remote processes
- Remote power — lock, sign out, reboot, shut down, run command
- Clipboard sync (two-way), chat
**Files**
- Browse/download/upload/rename/delete/mkdir, **folder upload** with drag & drop
- Atomic uploads (.part → final), folder creation on demand
**Security**
- **End-to-end encryption** (default): ephemeral ECDH P-256 → AES-256-GCM per frame — the VPS cannot see your screen
- Password never leaves the host un-hashed (PBKDF2 200k challenge/response, 0.4 s throttle per bad attempt)
- Optional **TOTP 2FA** (`--set-totp auto` → authenticator code required at connect)
- Optional server registration key so only your hosts can register; `/status` API behind a token
- The service broker relays opaque bytes only — agents hold all keys
**Accounts (RustDesk-style)**
- Sign up / sign in **in the desktop app and on the website** — no email
verification wall (the account works immediately; the email only confirms it)
- **My computers**: every PC where you sign in with your account is listed on
the website for one-click connect (online/offline/in-session status shown)
- Server address is **pre-filled** (netdesk.space) and no registration key is
needed by default; point the app at your own server anytime
- Pre-configured builds: name the exe `NetDeskHost-host=your.server,key=SECRET.exe`
and it auto-configures — hand it out with zero setup
**Viewer niceties**
- Address book (saved connections), remember-password, view-only mode
- **Session recording** to .webm (one click)
- **TCP tunneling** — reach any TCP service on the host (SSH, RDP, web UIs) through the server; portal-gated, with a panel in the viewer
- **Installable app (PWA)** — install the viewer like a native app from Chrome/Edge/Safari on laptop or phone
- **Host-side remote printing** — send a file to the host's printer from the file manager
- Multi-monitor switching, quality presets, live stats (fps/RTT/bitrate/E2E badge)
## Quick start (try on one PC)
```bat
cd path\to\netdesk
scripts\run_local_demo.bat
```
First run prints and saves a 9-digit ID and a random password
(`%APPDATA%\NetDesk\config.json`). Automated end-to-end test:
`.venv\Scripts\python tools\smoke_test.py` (23 checks: auth, 2FA, E2E video,
clipboard, processes, privacy, files, input, broker path…).
## Portable exe — a regular desktop app, no Python needed
`portable\` (and `..\NetDesk-portable-win64.zip`) ships standalone Windows x64
binaries built with PyInstaller, **tested end-to-end** (full 20-check suite
passes against the packaged exes):
| File | Purpose |
|---|---|
| `NetDeskHost.exe` (54 MB, windowed) | the agent **desktop app** — double-click it: a control panel opens with live status, your ID, server settings, password/2FA management, start/stop, and one-click service install |
| `NetDeskServer.exe` (14 MB, console) | server for a Windows VPS; serves the viewer **and the portal** |
Double-clicking `NetDeskHost.exe` opens the control panel (config + logs are
created next to the exe — the folder is fully portable). Advanced users can
still drive everything from a terminal (`--server …`, `--install`,
`--set-password`, `--set-totp auto`, `--uninstall`); the app itself is
windowless, so CLI output goes to `host.log` next to the exe. `--install`
sets up the same boot-time SYSTEM service with full logon-screen/UAC access.
## macOS (MacBook M4 / Apple Silicon and Intel)
Mac binaries cannot be cross-compiled from Windows, so the repo builds them ON
the Mac — native, one command:
```bash
git scp the netdesk folder to the Mac, then:
bash scripts/build_mac.sh # -> dist/NetDeskHost + dist/NetDeskServer (arm64)
```
- `NetDeskHost` on the Mac = control the Mac remotely (capture via mss, input
via CoreGraphics CGEvent, clipboard via pbpaste/pbcopy, process manager,
power via osascript). First run: grant **Screen Recording** and
**Accessibility** permissions (System Settings → Privacy & Security); if
Gatekeeper complains: `xattr -cr NetDeskHost`.
- Autostart on login: `scripts/deploy/com.netdesk.host.plist` → copy to
`~/Library/LaunchAgents/` (edit the path/server inside first),
then `launchctl load ~/Library/LaunchAgents/com.netdesk.host.plist`.
- `NetDeskServer` also builds on the Mac if you want a Mac VPS.
- macOS audio capture needs a loopback device (e.g. BlackHole) — off by default.
- Connecting TO a Mac or PC from the MacBook: just the browser — open the
server URL in Chrome/Edge/Safari; nothing to install.
## Web management portal
Start the server with `--portal-password "YourPortalPassword"` and open
`https://your-server:8443/portal` — a TeamViewer-console-style dashboard:
- **Computers list** — every host that ever registered, online/offline status,
version, last-seen, and a **Connect** button that deep-links into the viewer
(`/?id=<id>` — ID prefilled, just type the host password)
- **Live stats** — hosts online, sessions total, relayed traffic, uptime
- **Activity feed** — host online/offline, connect attempts, accepted sessions,
wrong-password/2FA failures (auth failures are visible server-side; passwords
never are)
- **Downloads** — anything you drop into the server's `files/` folder
(e.g. `NetDeskHost.exe`) is offered to logged-in users, so your travel laptop
can always grab the current agent from your own server
Access control: single portal password (cookie session, HttpOnly, 7 days).
The portal runs over the same TLS as everything else — keep TLS on for
internet use. By design the portal **cannot** see or store host passwords
(end-to-end encryption); connecting always needs the host's own password.
## Production setup
### 1. Server on your VPS (Debian/Ubuntu)
```bash
cd netdesk/scripts/deploy && sudo bash deploy_vps.sh # installs systemd service, prints REG_KEY
# add --portal-password "..." to the unit for the web portal at /portal
sudo ufw allow 8443/tcp
```
TLS (recommended): Let's Encrypt via certbot, then add `--cert/--key` to the unit
and restart. Quick alternative: `--gen-cert` (self-signed, accept browser warning once).
### 2. Host on your powerful PC (twice, then forget it)
```bat
:: once, as your user: pick the password and server
.venv\Scripts\python host\netdesk_host.py --set-password "YourStrongPassword"
.venv\Scripts\python host\netdesk_host.py --server wss://desk.yourdomain.com:8443/ws --reg-key REG_KEY --listen 8901
:: once, as Administrator: install the boot-time service
scripts\install_autostart.bat (runs: netdesk_host.py --install)
```
The installer copies credentials to `C:\ProgramData\NetDesk` and creates a
SYSTEM scheduled task (ONSTART) running the **broker**. The broker spawns a
**desktop agent** into your console session and re-spawns it on user switches.
Optional: `--set-totp auto` for a 2FA secret (print → add to your authenticator).
Optional: `pip install PyAudioWPatch` for remote sound.
### 3. Travel
Open `https://desk.yourdomain.com:8443/` — enter ID (+ 2FA code if enabled) and
password. Save it in the address book. Works on phones too. At the office logon
screen after a reboot? Connect and type your Windows password — you're in.
## Security model
TLS to the server + end-to-end AES-GCM media + PBKDF2 password proof + optional
TOTP + registration key. Single session per host; second viewer gets "host busy".
Everything is stateless on the VPS.
## Silent operation & what "undetected" means here
Like RustDesk 1.2, NetDesk is invisible on the host while you work:
- The engine is **headless** — no window, no tray icon, nothing pops up when a
session starts. In service mode it runs as a hidden SYSTEM task from boot.
- **Privacy mode** turns the physical monitor off while you keep working.
Enable **"Blank the monitor while controlled"** in the app (or per-session
via the viewer's privacy button) and the screen goes dark the moment a
viewer connects; it comes back when the session ends.
- Sessions inject input only; a person sitting at the PC would see the mouse
move (true for RustDesk/TeamViewer too — blocking local input needs a driver).
Be honest about the detection surface, because "undetected" does not mean
"forensically invisible": the process shows in Task Manager, Windows sees the
scheduled task, the host keeps logs (`host.log`), and the machine makes an
outbound connection to your VPS. RustDesk has the same surfaces. That is the
correct trade-off for administering **your own or explicitly authorized
computers**; covertly controlling a machine you do not own or are not
authorized to manage is illegal in most jurisdictions and this tool is not
designed for it.
## Honest limitations
- No UDP P2P hole-punching: browsers can't send raw UDP, so traffic relays
through your VPS (~1 extra RTT; same as RustDesk when its punch fails). Direct
mode (`--listen` + port-forward) bypasses the VPS on your LAN.
- Remote printing is not implemented (needs a printer driver); use file transfer + local printing.
- True privacy mode (fake desktop) needs a virtual display driver — NetDesk turns
the physical monitor off instead.
- Session-0 notes: input at the logon screen/UAC requires the service install
(agent as SYSTEM). Ctrl+Alt+Del itself is never injectable without a driver.
- No mobile native apps (the web viewer works in phone browsers).
## Files
```
netdesk/
├── server/netdesk_server.py # signaling + relay + web host (stateless)
├── server/web/ # viewer UI
├── host/netdesk_host.py # broker + desktop agent + service installer
├── tools/smoke_test.py # 23-check end-to-end suite
├── scripts/ # local demo, service install, VPS deploy
└── requirements.txt
```
## Troubleshooting
| Symptom | Fix |
|---|---|
| "host offline" | Broker not running: `schtasks /Run /TN NetDesk`, or start `--broker` manually; logs in the config folder's `host.log` |
| "bad 2FA code" | Host clock drift — resync Windows time, or re-set `--set-totp` |
| Black screen + "logon screen" banner | Nobody logged in — type credentials via keyboard, desktop switches automatically |
| Input dead on UAC prompt | Only in simple (non-service) mode — install the service for SYSTEM desktop access |
| Choppy | Auto quality preset, or direct mode on LAN |
| E2E badge missing | Old browser — needs WebCodecs + WebCrypto (Chrome/Edge 102+) |
## Get the source code
The full source is published on the project website (no GitHub account needed):
```bash
# Clone directly from netdesk.space
git clone https://netdesk.space/git/netdesk.git
# …or download a snapshot
# https://netdesk.space/source → .tar.gz / .zip
```
## Repository layout
```
netdesk/
├── server/ Python aiohttp signaling + relay + web client
├── host/ Python Windows host/agent + desktop GUI (pywebview)
├── native/ from-scratch native client (Rust core + Flutter UI)
├── docker/ Dockerfile + compose for the server
├── scripts/ tools/ helper + maintenance scripts
└── requirements.txt Python deps for the server/host
```
## License
Source-available — free to self-host and modify for your own use. See `LICENSE`.
+39
View File
@@ -0,0 +1,39 @@
# NetDesk server config — copy to `.env` and fill in. `.env` is gitignored.
# Every value is optional; the server runs with sensible defaults, but you want
# at least a status key + portal password + TURN secret for a real deployment.
# Public port to publish the server on (maps to container :8443).
ND_PORT=8443
# Secret required to read /status details (pick a long random string).
ND_STATUS_KEY=
# Secret hosts must present to register (recommended; blocks stranger hosts).
ND_REG_KEY=
# Password for the web management portal (/portal).
ND_PORTAL_PASSWORD=
# Email granted unrestricted access + the admin panel.
ND_OWNER_EMAIL=
# Public base URL used in verification emails, e.g. https://netdesk.example.com
ND_PUBLIC_URL=
# Email accounts (optional): sender address + SMTP relay for verification mail.
ND_MAIL_FROM=
ND_SMTP_HOST=127.0.0.1
# Self-hosted coturn (optional): the server mints 12h HMAC TURN creds against
# this secret; the coturn sidecar shares it. TURN_REALM is the coturn realm.
ND_TURN_HOST=
ND_TURN_SECRET=
TURN_REALM=netdesk.local
# In-process TLS (optional): paths INSIDE the container (mount certs in compose).
# Usually you terminate TLS at a reverse proxy instead and leave these blank.
ND_CERT=
ND_KEY=
# Set to 1 for debug logging.
ND_VERBOSE=0
+29
View File
@@ -0,0 +1,29 @@
# NetDesk signaling/relay server — container image.
# Build context is the repo root (so it can copy server/):
# docker build -f docker/Dockerfile -t netdesk-server .
# or just `docker compose up -d --build` from docker/.
FROM python:3.12-slim
# aiohttp + cryptography (the server's only third-party deps). Pinned to the
# versions the production host runs.
RUN pip install --no-cache-dir aiohttp==3.9.1 cryptography==41.0.7
WORKDIR /app/server
# Server code + the web assets it serves (web/ is resolved relative to the
# script, so it must sit next to netdesk_server.py).
COPY server/netdesk_server.py /app/server/netdesk_server.py
COPY server/web /app/server/web
# Persistent state (accounts.db, audit.log, files/) lives on a mounted volume,
# kept OUT of the image + the code dir via ND_DATA_DIR (see _nd_data_root()).
ENV ND_DATA_DIR=/data
RUN mkdir -p /data
VOLUME ["/data"]
COPY docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# The server listens on 8443 (plain HTTP/WS; terminate TLS at a reverse proxy,
# or pass ND_CERT/ND_KEY to enable TLS in-process).
EXPOSE 8443
ENTRYPOINT ["docker-entrypoint.sh"]
+41
View File
@@ -0,0 +1,41 @@
# NetDesk server — Docker
Run a full self-hosted NetDesk (signaling + relay + web viewer/host + accounts +
portal + audit) with one command, plus an optional coturn TURN sidecar.
```sh
cd docker
cp .env.example .env # then edit .env — at least ND_STATUS_KEY + ND_PORTAL_PASSWORD
docker compose up -d --build
```
The server listens on `:8443` (plain HTTP/WS). For the public internet, terminate
TLS at a reverse proxy (nginx/Caddy/Traefik) in front of it, or set `ND_CERT` /
`ND_KEY` in `.env` (and mount the certs in `docker-compose.yml`) to run TLS
in-process.
Quick check once it's up:
```sh
curl "http://localhost:8443/status?token=$ND_STATUS_KEY"
```
## Persistence
All state — `accounts.db`, `audit.log`, and uploaded `files/` — lives in the
named volume `netdesk-data` (mounted at `/data` via `ND_DATA_DIR`), so it
survives `docker compose down` / image rebuilds. Back it up with
`docker run --rm -v netdesk-data:/data -v "$PWD":/backup busybox tar czf /backup/netdesk-data.tgz -C /data .`.
## Config
Everything is set through `.env` (see `.env.example` for the full list); the
entrypoint maps `ND_*` vars to the server's CLI flags. Only non-empty vars are
passed, so the defaults apply otherwise.
## TURN
The `coturn` sidecar runs with `--use-auth-secret` sharing `ND_TURN_SECRET`; the
server mints 12-hour HMAC credentials against the same secret. It uses host
networking because TURN needs the real relay port range. Leave `ND_TURN_HOST` /
`ND_TURN_SECRET` blank to disable TURN (direct P2P + the WSS relay still work).
+44
View File
@@ -0,0 +1,44 @@
# NetDesk self-hosted stack: the signaling/relay/web server + a coturn TURN
# sidecar. One command to stand up a full self-hosted NetDesk:
#
# cd docker && cp .env.example .env # then edit .env (secrets)
# docker compose up -d --build
#
# Terminate TLS at your reverse proxy (nginx/Caddy/Traefik) in front of :8443,
# or set ND_CERT/ND_KEY + mount the certs to run TLS in-process.
services:
netdesk:
build:
context: .. # repo root, so the image can COPY server/
dockerfile: docker/Dockerfile
image: netdesk-server:latest
restart: unless-stopped
ports:
- "${ND_PORT:-8443}:8443" # HTTP/WS (put a TLS proxy in front for the internet)
env_file: .env
volumes:
- netdesk-data:/data # accounts.db, audit.log, files/ (ND_DATA_DIR)
# If you enable in-process TLS, also mount your certs and set ND_CERT/ND_KEY:
# - ./certs:/certs:ro
# Self-hosted TURN (use-auth-secret): the server mints 12h HMAC creds against
# ND_TURN_SECRET, and peers relay through here when direct P2P can't punch NAT.
# Set ND_TURN_HOST + ND_TURN_SECRET in .env (and TURN_REALM) to activate it.
coturn:
image: coturn/coturn:4.6.2
restart: unless-stopped
network_mode: host # TURN needs the real host ports / relay range
command:
- -n
- --use-auth-secret
- --static-auth-secret=${ND_TURN_SECRET:-changeme}
- --realm=${TURN_REALM:-netdesk.local}
- --listening-port=3478
- --tls-listening-port=5349
- --min-port=49152
- --max-port=65535
- --no-cli
- --fingerprint
volumes:
netdesk-data:
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env sh
# Map ND_* environment variables to netdesk_server.py CLI flags, so secrets stay
# out of the image and the compose file (they come from the .env file / the
# environment). Only non-empty vars are passed, so every flag is optional.
set -eu
set -- --port "${ND_PORT:-8443}" --bind "${ND_BIND:-0.0.0.0}"
[ -n "${ND_STATUS_KEY:-}" ] && set -- "$@" --status-key "$ND_STATUS_KEY"
[ -n "${ND_REG_KEY:-}" ] && set -- "$@" --reg-key "$ND_REG_KEY"
[ -n "${ND_PORTAL_PASSWORD:-}" ] && set -- "$@" --portal-password "$ND_PORTAL_PASSWORD"
[ -n "${ND_ACCESS_PATH:-}" ] && set -- "$@" --access-path "$ND_ACCESS_PATH"
[ -n "${ND_OWNER_EMAIL:-}" ] && set -- "$@" --owner-email "$ND_OWNER_EMAIL"
[ -n "${ND_PUBLIC_URL:-}" ] && set -- "$@" --public-url "$ND_PUBLIC_URL"
[ -n "${ND_MAIL_FROM:-}" ] && set -- "$@" --mail-from "$ND_MAIL_FROM"
[ -n "${ND_SMTP_HOST:-}" ] && set -- "$@" --smtp-host "$ND_SMTP_HOST"
[ -n "${ND_TURN_HOST:-}" ] && set -- "$@" --turn-host "$ND_TURN_HOST"
[ -n "${ND_TURN_SECRET:-}" ] && set -- "$@" --turn-secret "$ND_TURN_SECRET"
[ "${ND_ALWAYS_USE_RELAY:-0}" = "1" ] && set -- "$@" --always-use-relay
[ -n "${ND_CERT:-}" ] && set -- "$@" --cert "$ND_CERT"
[ -n "${ND_KEY:-}" ] && set -- "$@" --key "$ND_KEY"
[ "${ND_VERBOSE:-0}" = "1" ] && set -- "$@" --verbose
# ND_TURN_* for Cloudflare TURN are read from the environment by the server
# itself (argparse defaults to os.environ), so they need no mapping here.
echo "[entrypoint] exec python netdesk_server.py $*"
exec python /app/server/netdesk_server.py "$@"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+971
View File
@@ -0,0 +1,971 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetDesk v1.5.0 — Enterprise Remote Desktop</title>
<style>
:root {
--win-bg: #11161d; --sidebar-bg: #0d1117; --card-bg: #181f29; --card-border: #26303f;
--input-bg: #0f141b; --input-border: #252f3d;
--em: #19c87e; --em-br: #25db8e; --crimson-bg: #321c22; --crimson-bd: #7c3139; --crimson-fg: #e05a61;
--blue: #3b9eff; --fg: #e6edf3; --muted: #8e9aa9; --faint: #64748b;
}
* { box-sizing: border-box; user-select: none; -webkit-font-smoothing: antialiased; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: radial-gradient(circle at 18% 15%, rgba(25,200,126,.08) 0%, transparent 42%),
radial-gradient(circle at 82% 85%, rgba(59,158,255,.07) 0%, transparent 45%),
linear-gradient(180deg, #0b0f15 0%, #06080c 100%);
font-family: 'Segoe UI', 'Inter', system-ui, sans-serif; color: var(--fg); font-size: 13.5px;
overflow: hidden;
}
.mono { font-family: 'Cascadia Mono', Consolas, monospace; user-select: text; }
input, textarea { user-select: text; }
.shell { height: 100vh; display: flex; flex-direction: column; background: var(--win-bg);
border: 1px solid #263241; overflow: hidden; }
/* ===== titlebar ===== */
.tbar { height: 38px; background: #0a0e13; border-bottom: 1px solid #1d2633; display: flex;
align-items: center; justify-content: space-between; padding-left: 12px; flex-shrink: 0; }
.tbar-l { display: flex; align-items: center; gap: 10px; }
.tbar-logo { width: 18px; height: 18px; border-radius: 4px; background: linear-gradient(180deg,#25d386,#16a765);
display: flex; align-items: center; justify-content: center; }
.tbar-title { font-size: 12.5px; color: #d8dee9; font-weight: 500; }
.tbar-sub { font-size: 11px; color: var(--faint); }
.telemetry { display: flex; align-items: center; gap: 10px; font-size: 11px; color: #8b96a6;
background: #131922; border: 1px solid #222c3a; padding: 3px 12px; border-radius: 999px; }
.telemetry .tdot { width: 6px; height: 6px; border-radius: 50%; background: var(--em); }
.telemetry .sep { color: #334155; }
.telemetry b { color: #cbd5e1; font-weight: 600; }
#pingMs { color: var(--em); }
.winbtns { display: flex; height: 100%; }
.wbtn { width: 44px; height: 100%; display: flex; align-items: center; justify-content: center;
color: #94a3b8; background: none; border: none; cursor: pointer; transition: .12s; }
.wbtn:hover { background: rgba(255,255,255,.08); color: #fff; }
.wbtn.close:hover { background: #e81123; color: #fff; }
/* ===== layout ===== */
.body { flex: 1; display: flex; min-height: 0; }
/* sidebar */
.side { width: 236px; background: var(--sidebar-bg); border-right: 1px solid #1e2734;
display: flex; flex-direction: column; justify-content: space-between; padding: 14px; flex-shrink: 0; }
.brand { display: flex; align-items: center; gap: 12px; padding: 2px 2px 16px; border-bottom: 1px solid #1c2430; }
.brand-logo { width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(180deg,#24cf83,#16ad69);
display: flex; align-items: center; justify-content: center; box-shadow: 0 3px 14px rgba(25,200,126,.32); flex-shrink: 0; }
.brand-name { font-size: 21px; font-weight: 700; color: var(--em); letter-spacing: -.3px; line-height: 1; }
.brand-ver { font-size: 12px; color: #6e7b8b; }
.brand-tag { font-size: 10.5px; color: #8b96a6; display: block; margin-top: 4px; }
.navsec { font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;
color: #586577; padding: 14px 8px 6px; }
.nav-item { display: flex; align-items: center; justify-content: space-between; width: 100%;
padding: 9px 11px; border-radius: 8px; font-size: 13px; font-weight: 500; color: #94a3b8;
background: transparent; border: 1px solid transparent; cursor: pointer; transition: .15s;
text-align: left; margin-bottom: 3px; font-family: inherit; }
.nav-item:hover { color: #e2e8f0; background: rgba(255,255,255,.04); }
.nav-item.active { color: #fff; background: linear-gradient(90deg, rgba(25,200,126,.16), rgba(25,200,126,.03));
border-color: rgba(25,200,126,.32); }
.nav-item .nl { display: flex; align-items: center; gap: 10px; }
.nav-item svg { flex-shrink: 0; }
.nav-badge { font-size: 10px; padding: 2px 7px; border-radius: 6px; font-weight: 600; }
.nb-em { background: rgba(25,200,126,.15); color: var(--em); }
.nb-red { background: var(--crimson-bg); color: var(--crimson-fg); border: 1px solid var(--crimson-bd); }
.nb-grey { background: #1e293b; color: #94a3b8; }
.side-info { background: #131922; border: 1px solid #222c3a; border-radius: 9px; padding: 10px 11px; margin-bottom: 10px; }
.side-row { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: #8b96a6; }
.side-row + .side-row { margin-top: 5px; }
.acct-chip { display: flex; align-items: center; gap: 10px; padding: 2px 4px; }
.acct-ava { width: 28px; height: 28px; border-radius: 50%; background: rgba(25,200,126,.2);
border: 1px solid rgba(25,200,126,.4); display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: var(--em); flex-shrink: 0; }
.acct-mail { font-size: 11.5px; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.acct-sub { font-size: 10px; color: var(--em); }
/* main */
.main { flex: 1; padding: 16px 18px; display: flex; flex-direction: column; min-width: 0; overflow-y: auto;
background-image: linear-gradient(to right, rgba(255,255,255,.014) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,.014) 1px, transparent 1px);
background-size: 28px 28px; }
.toprow { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.connectbar { display: flex; align-items: center; background: var(--input-bg); border: 1px solid #263242;
border-radius: 9px; padding: 4px 4px 4px 12px; gap: 8px; flex: 1; max-width: 560px; transition: .15s; }
.connectbar:focus-within { border-color: var(--em); }
.cb-lbl { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; color: var(--em); flex-shrink: 0; }
.cb-input { background: transparent; border: none; outline: none; color: #fff; flex: 1; min-width: 120px;
font-family: 'Cascadia Mono', Consolas, monospace; font-size: 13px; }
.cb-input::placeholder { color: #556274; font-family: 'Segoe UI', sans-serif; }
.cb-go { background: var(--em); color: #08110c; font-weight: 700; font-size: 12px; padding: 7px 13px;
border-radius: 7px; border: none; cursor: pointer; display: flex; align-items: center; gap: 5px; transition: .15s; flex-shrink: 0; }
.cb-go:hover { background: #22db8d; }
.portable-pill { background: #8c949e; color: #11161d; font-size: 12px; font-weight: 700; padding: 4px 12px;
border-radius: 999px; border: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.portable-note { font-size: 12px; color: #6d7888; margin-left: 8px; }
/* cards */
.grid2 { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 14px; }
.col { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.card { background: linear-gradient(180deg, rgba(27,35,46,.95), rgba(22,29,38,.95));
border: 1px solid var(--card-border); border-radius: 10px; padding: 16px;
box-shadow: 0 6px 20px rgba(0,0,0,.22); transition: border-color .2s; }
.card:hover { border-color: #313f52; }
.card h2 { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); }
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.tag-sm { font-size: 10.5px; font-weight: 600; padding: 2px 8px; border-radius: 5px; }
.tag-em { background: rgba(25,200,126,.1); color: var(--em); border: 1px solid rgba(25,200,126,.3); }
/* id card */
.id-big { font-family: 'Cascadia Mono', Consolas, monospace; font-size: 34px; font-weight: 800;
color: #fff; letter-spacing: .5px; line-height: 1.15; user-select: text; }
.btn-copy { background: #232b38; border: 1px solid #384558; color: var(--fg); border-radius: 9px;
padding: 9px 14px; font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px;
cursor: pointer; transition: .15s; }
.btn-copy:hover { background: #2c3646; border-color: #4b5b70; }
.btn-copy:active { transform: translateY(1px); }
.fmt-btn { font-size: 10.5px; color: var(--faint); background: #11161d; border: 1px solid var(--card-border);
border-radius: 5px; padding: 2px 7px; cursor: pointer; transition: .12s; }
.fmt-btn:hover { color: #cbd5e1; }
.status-pill { display: inline-flex; align-items: center; gap: 8px; padding: 4px 13px; border-radius: 999px;
font-size: 12.5px; font-weight: 600; transition: .2s; }
.status-pill .sdot { width: 8px; height: 8px; border-radius: 50%; }
.sp-stopped { background: var(--crimson-bg); border: 1px solid var(--crimson-bd); color: var(--crimson-fg); }
.sp-stopped .sdot { background: var(--crimson-fg); }
.sp-running { background: rgba(25,200,126,.12); border: 1px solid rgba(25,200,126,.45); color: var(--em-br); }
.sp-running .sdot { background: var(--em-br); animation: pulse 1.8s infinite; }
.sp-conn { background: #33301c; border: 1px solid #7c6535; color: #d9b96a; }
.sp-conn .sdot { background: #d9b96a; }
@keyframes pulse { 0%,100%{box-shadow:0 0 0 0 rgba(37,219,142,.6)} 50%{box-shadow:0 0 0 6px rgba(37,219,142,0)} }
.e2e-tag { display: inline-flex; align-items: center; gap: 5px; background: #11161d; border: 1px solid #242e3d;
padding: 3px 9px; border-radius: 6px; font-size: 11.5px; color: #94a3b8; }
/* inputs */
.nd-input { width: 100%; height: 38px; background: var(--input-bg); border: 1px solid var(--input-border);
border-radius: 7px; padding: 0 12px; font-size: 13.5px; color: #f0f6fc; outline: none; transition: .16s; }
.nd-input::placeholder { color: #556274; }
.nd-input:focus { border-color: var(--em); box-shadow: 0 0 0 3px rgba(25,200,126,.15); }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 13px; color: #9aa6b5; margin-bottom: 6px; }
.field .opt { color: #637081; }
.icon-btn { width: 38px; height: 38px; border-radius: 7px; background: #262f3d; border: 1px solid #384556;
color: #d0d8e2; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
flex-shrink: 0; transition: .15s; }
.icon-btn:hover { background: #303b4c; border-color: #4b5b70; color: #fff; }
.icon-btn:active { transform: translateY(1px); }
.link-btn { background: none; border: none; color: var(--blue); font-size: 13.5px; font-weight: 600;
cursor: pointer; padding: 4px; flex-shrink: 0; }
.link-btn:hover { color: #66b3ff; text-decoration: underline; }
.badge-2fa { background: var(--crimson-bg); border: 1px solid var(--crimson-bd); color: var(--crimson-fg);
border-radius: 6px; padding: 8px 10px; font-size: 12.5px; font-weight: 600; cursor: pointer; flex-shrink: 0; transition: .15s; }
.badge-2fa.on { background: rgba(25,200,126,.12); border-color: rgba(25,200,126,.45); color: var(--em-br); }
.helper { font-size: 12.5px; color: #738091; line-height: 1.5; margin-top: 12px; }
.card-foot { margin-top: 14px; padding-top: 12px; border-top: 1px solid #222b38; display: flex;
align-items: center; justify-content: space-between; gap: 8px; font-size: 11.5px; color: #8b96a6; flex-wrap: wrap; }
.gr-link { color: var(--em); background: none; border: none; cursor: pointer; font-weight: 500; font-size: 11.5px; }
.gr-link:hover { text-decoration: underline; }
/* account */
.acct-line { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.acct-ok { font-size: 13.5px; color: var(--em); font-weight: 500; }
.fleet { margin-top: 14px; padding-top: 12px; border-top: 1px solid #222b38; }
.fleet-head { display: flex; align-items: center; justify-content: space-between; font-size: 11px;
color: #8b96a6; margin-bottom: 8px; }
.fleet-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.fleet-card { background: #11161d; border: 1px solid #253040; border-radius: 7px; padding: 7px 10px;
display: flex; align-items: center; justify-content: space-between; gap: 6px; min-width: 0; transition: .15s; }
.fleet-card.click { cursor: pointer; } .fleet-card.click:hover { border-color: rgba(25,200,126,.5); }
.fleet-name { font-size: 11.5px; font-weight: 600; color: #d0d8e2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fleet-sub { font-size: 10px; color: var(--faint); }
.fleet-sub.on { color: var(--em); }
.fdot { width: 8px; height: 8px; border-radius: 50%; background: #64748b; flex-shrink: 0; }
.fdot.on { background: var(--em); }
/* start/stop */
.startrow { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 14px; }
.btn-start { height: 44px; border-radius: 8px; background: linear-gradient(180deg,#22d084,#16b56f);
border: 1px solid #35e699; box-shadow: 0 0 22px rgba(25,200,126,.35), inset 0 1px 0 rgba(255,255,255,.22);
color: #fff; font-size: 14.5px; font-weight: 700; display: flex; align-items: center; justify-content: center;
gap: 9px; cursor: pointer; transition: .18s; font-family: inherit; }
.btn-start:hover { filter: brightness(1.06); }
.btn-start:active { transform: translateY(1px); }
.btn-stop { height: 44px; border-radius: 8px; background: linear-gradient(180deg,#555f6d,#48515e);
border: 1px solid #647080; color: #c7d0dc; font-size: 14px; font-weight: 600; display: flex;
align-items: center; justify-content: center; gap: 8px; cursor: pointer; transition: .18s; font-family: inherit; }
.btn-stop:hover { background: linear-gradient(180deg,#606b7b,#515b69); color: #fff; }
.stop-sq { width: 12px; height: 12px; border-radius: 2px; background: currentColor; }
/* toggles */
.togglecard { margin-top: 14px; padding: 14px 16px; }
.toggles { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; text-align: center; }
.tg { display: flex; flex-direction: column; align-items: center; }
.sw { width: 48px; height: 25px; border-radius: 999px; position: relative; cursor: pointer;
transition: .22s cubic-bezier(.4,0,.2,1); padding: 0; outline: none; margin-bottom: 8px; flex-shrink: 0; }
.sw.on { background: linear-gradient(180deg,#24d286,#18b872); border: 1px solid #39e59a;
box-shadow: 0 0 12px rgba(25,200,126,.38); }
.sw.off { background: #323b48; border: 1px solid #475364; box-shadow: inset 0 1px 3px rgba(0,0,0,.45); }
.sw-thumb { width: 19px; height: 19px; border-radius: 50%; position: absolute; top: 2px; left: 2px;
transition: transform .22s cubic-bezier(.4,0,.2,1), background .2s; }
.sw.on .sw-thumb { transform: translateX(23px); background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); }
.sw.off .sw-thumb { background: #8b96a6; }
.tg-lb { font-size: 12.5px; color: #9aa6b5; line-height: 1.3; }
/* footer buttons */
.footrow { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 14px; }
.btn-foot { height: 40px; background: #161d26; border: 1px solid #1f7a53; border-radius: 8px;
box-shadow: 0 0 10px rgba(25,200,126,.12); color: #d9e2ec; font-size: 13.5px; font-weight: 500;
display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer; transition: .16s; font-family: inherit; }
.btn-foot:hover { background: #1c2632; border-color: #27be7c; color: #fff; }
.btn-foot svg { stroke: var(--em); }
/* views */
.view { display: none; } .view.active { display: block; }
.list-row { display: flex; align-items: center; justify-content: space-between; gap: 10px;
background: #11161d; border: 1px solid #253040; border-radius: 8px; padding: 11px 14px; margin-bottom: 8px; }
.logbox { background: #0a0e13; border: 1px solid #1e2734; border-radius: 8px; padding: 12px;
font-family: 'Cascadia Mono', Consolas, monospace; font-size: 11.5px; color: #9fb0c4; line-height: 1.7;
max-height: 480px; overflow-y: auto; white-space: pre-wrap; user-select: text; }
/* modal + toast */
.modal-bg { position: fixed; inset: 38px 0 0 0; background: rgba(7,10,15,.8); backdrop-filter: blur(5px);
z-index: 40; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-bg.open { display: flex; }
.modal { width: 100%; max-width: 500px; background: #161d27; border: 1px solid #2a3648; border-radius: 12px;
box-shadow: 0 24px 70px rgba(0,0,0,.7); overflow: hidden; }
.modal-h { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px;
background: #10151d; border-bottom: 1px solid #232d3c; }
.modal-h h3 { font-size: 14px; font-weight: 700; color: var(--em); }
.modal-x { background: none; border: none; color: #8b96a6; cursor: pointer; font-size: 14px; padding: 2px 8px; }
.modal-x:hover { color: #fff; }
.modal-b { padding: 16px; font-size: 13px; color: #cbd5e1; line-height: 1.6; max-height: 440px; overflow-y: auto; }
.modal-f { padding: 12px 16px; background: #10151d; border-top: 1px solid #232d3c; display: flex; justify-content: flex-end; gap: 8px; }
.modal-ok { padding: 8px 18px; border-radius: 7px; background: var(--em); color: #07120c; font-weight: 700;
font-size: 12.5px; border: none; cursor: pointer; }
.modal-ok:hover { background: #22db8d; }
.mono-box { font-family: Consolas, monospace; background: var(--input-bg); color: var(--em);
padding: 2px 8px; border-radius: 5px; user-select: text; word-break: break-all; }
#toasts { position: fixed; bottom: 16px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { background: rgba(19,25,34,.96); border: 1px solid #2c3849; border-left: 3px solid var(--em);
border-radius: 9px; padding: 10px 14px; box-shadow: 0 10px 30px rgba(0,0,0,.5); font-size: 12px;
transition: .25s; min-width: 220px; }
.toast b { color: #fff; display: block; }
.toast span { color: #94a3b8; font-size: 11px; }
::-webkit-scrollbar { width: 7px; height: 7px; }
::-webkit-scrollbar-track { background: #0d1117; }
::-webkit-scrollbar-thumb { background: #283342; border-radius: 3px; }
/* fitZoom keeps the effective layout ≥1080px, and CSS zoom does NOT affect
media queries - so collapse only at truly tiny raw window widths */
@media (max-width: 660px) { .grid2 { grid-template-columns: 1fr; } .toggles { grid-template-columns: repeat(3,1fr); row-gap: 16px; } .fleet-grid { grid-template-columns: 1fr 1fr; } }
/* ---- fleet toolbar / chips / allowlist (1.5) ---- */
.main { overflow-x: hidden; }
.dev-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.dev-search { flex: 1; min-width: 200px; height: 34px; background: #0d131b; border: 1px solid #263241;
border-radius: 8px; color: #e2e8f0; font-size: 12.5px; padding: 0 12px; outline: none; }
.dev-search:focus { border-color: #2f6f4f; }
.chips { display: inline-flex; gap: 6px; }
.chip { height: 28px; padding: 0 12px; border-radius: 999px; border: 1px solid #263241;
background: #0d131b; color: #94a3b8; font-size: 11.5px; cursor: pointer; }
.chip.active { background: rgba(34,197,94,.12); border-color: #2f6f4f; color: #4ade80; }
.wake-btn { height: 28px; padding: 0 12px; border-radius: 7px; border: 1px solid #3b4a5e;
background: #17202b; color: #cbd5e1; font-size: 11.5px; cursor: pointer; }
.wake-btn:hover { border-color: #4ade80; color: #4ade80; }
#qrWrap { display: flex; }
</style>
</head>
<body>
<div class="shell">
<!-- titlebar -->
<header class="tbar">
<div class="tbar-l pywebview-drag-region" style="flex:1;height:100%;align-self:stretch">
<span class="tbar-logo"><svg width="11" height="11" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6H20.8L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.5H7.6L8.7 15.1Z" fill="#fff"/></svg></span>
<span class="tbar-title">NetDesk</span>
<span class="tbar-sub">— Enterprise Remote Desktop &amp; Unattended Access</span>
</div>
<div class="telemetry pywebview-drag-region">
<span style="display:flex;align-items:center;gap:6px"><span class="tdot" id="relayDot"></span>Relay: <b class="mono" id="relayHost">…</b></span>
<span class="sep">|</span><span class="mono" id="pingMs">— ms</span>
<span class="sep">|</span><span>TLS 1.3 • AES-256-GCM</span>
</div>
<div class="winbtns">
<button class="wbtn" id="winMin" title="Minimize"><svg width="11" height="11" viewBox="0 0 11 11"><rect x="1" y="5" width="9" height="1" fill="currentColor"/></svg></button>
<button class="wbtn" id="winMax" title="Maximize"><svg width="10" height="10" viewBox="0 0 10 10" fill="none"><rect x="1" y="1" width="8" height="8" stroke="currentColor"/></svg></button>
<button class="wbtn close" id="winClose" title="Close"><svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M1.5 1.5L9.5 9.5M9.5 1.5L1.5 9.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg></button>
</div>
</header>
<div class="body">
<!-- sidebar -->
<aside class="side">
<div>
<div class="brand">
<div class="brand-logo"><svg width="23" height="23" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6C2.8 13 3.1 13.7 3.7 13.7H20.3C20.9 13.7 21.2 13 20.8 12.6L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.3C16.5 20.7 16.2 21 15.8 21H8.2C7.8 21 7.5 20.7 7.6 20.3L8.7 15.1Z" fill="#fff"/></svg></div>
<div><span class="brand-name">NetDesk</span> <span class="brand-ver">v1.5.0</span>
<span class="brand-tag">Zero-Trust Remote Agent</span></div>
</div>
<div class="navsec">Workspace</div>
<button class="nav-item active" data-view="dash">
<span class="nl"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#19c87e" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg><span>Remote Control</span></span>
<span class="nav-badge nb-em">Host</span></button>
<button class="nav-item" data-view="devs">
<span class="nl"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#60a5fa" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/></svg><span>My Computers</span></span>
<span class="nav-badge nb-grey mono" id="navDevCount">0</span></button>
<button class="nav-item" data-view="sec">
<span class="nl"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Security &amp; 2FA</span></span>
<span class="nav-badge nb-red" id="nav2fa">2FA Off</span></button>
<button class="nav-item" data-view="logs">
<span class="nl"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg><span>Session Logs</span></span>
<span class="nav-badge nb-grey mono" id="navLogN">·</span></button>
</div>
<div>
<div class="side-info">
<div class="side-row"><span>Local Endpoint</span><span class="mono" style="color:var(--em)" id="sideHost">…</span></div>
<div class="side-row"><span>Daemon Status</span><span id="sideStatus" style="color:var(--crimson-fg);font-weight:600">● Stopped</span></div>
</div>
<div class="acct-chip" id="sideAcct" style="display:none">
<div class="acct-ava" id="sideAva">–</div>
<div style="min-width:0"><div class="acct-mail" id="sideMail"></div><div class="acct-sub">✓ Cloud Synced</div></div>
</div>
</div>
</aside>
<!-- main -->
<main class="main">
<div class="toprow">
<div class="connectbar">
<span class="cb-lbl">Connect to Partner:</span>
<input class="cb-input" id="partnerId" placeholder="Enter remote Partner ID (e.g. 842 190 441)" maxlength="12">
<button class="cb-go" id="partnerGo">Connect
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
</div>
<div style="display:flex;align-items:center;flex-shrink:0">
<button class="portable-pill" id="portableBtn"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg> Portable</button>
<span class="portable-note">settings next to the app</span>
</div>
</div>
<!-- DASHBOARD VIEW -->
<div class="view active" id="view-dash">
<div class="grid2">
<div class="col">
<!-- ID card -->
<section class="card">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px">
<div>
<div style="display:flex;align-items:center;gap:8px"><h2>This Computer's ID</h2>
<button class="fmt-btn" id="fmtBtn" title="Toggle spaced format">Format</button></div>
<div class="id-big" id="bigId">…</div>
</div>
<button class="btn-copy" id="copyBtn"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><span id="copyLbl">Copy ID</span></button>
</div>
<div style="margin-top:12px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
<span class="status-pill sp-stopped" id="statusPill"><span class="sdot"></span><span id="statusTxt">Stopped</span></span>
<span class="e2e-tag"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#19c87e" stroke-width="2.2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> E2EE Ready</span>
</div>
</section>
<!-- Password card -->
<section class="card" style="flex:1">
<div class="card-head"><h2>Unattended Password</h2>
<span class="tag-sm tag-em" id="pwBadge">PBKDF2 · 200k</span></div>
<div style="display:flex;align-items:center;gap:8px">
<input type="password" id="pwInput" class="nd-input mono" style="letter-spacing:1px" placeholder="no password yet — generate one">
<button class="icon-btn" id="pwEye" title="Show / hide"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="icon-btn" id="pwGen" title="Generate a strong random password"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
<button class="link-btn" id="pwSet">Set</button>
<button class="badge-2fa" id="twoFa">2FA: off</button>
</div>
<p class="helper">Anyone who knows this ID and password can control this PC. Set a strong
password; turn on 2FA for an extra authenticator code at connect.</p>
<div class="card-foot">
<span style="display:flex;align-items:center;gap:6px"><span style="width:6px;height:6px;border-radius:50%;background:var(--em)"></span>Brute-force throttle: <b style="color:#cbd5e1">0.4s / attempt</b></span>
<button class="gr-link" id="go2fa">Configure Authenticator App →</button>
</div>
</section>
</div>
<div class="col">
<!-- Connection card -->
<section class="card">
<div class="card-head"><h2>Connection</h2>
<button class="gr-link" id="pingBtn" style="background:rgba(25,200,126,.1);border:1px solid rgba(25,200,126,.3);border-radius:5px;padding:3px 9px">Ping Relay</button></div>
<div class="field"><label for="srvIn">Server Address</label>
<input id="srvIn" class="nd-input mono" style="font-size:13px" spellcheck="false"></div>
<div class="field"><label for="regIn">Registration Key <span class="opt">(optional)</span></label>
<input id="regIn" class="nd-input mono" style="font-size:13px" placeholder="Leave blank for open relay" spellcheck="false"></div>
<div class="field" style="margin-bottom:0"><label for="nameIn">Computer Name</label>
<input id="nameIn" class="nd-input" spellcheck="false"></div>
</section>
<!-- Account card -->
<section class="card" style="flex:1">
<div class="card-head"><h2>NetDesk Account</h2>
<span class="tag-sm tag-em" id="acctBadge" style="display:none">Fleet Synced</span></div>
<div id="acctOut">
<div class="field"><input id="accEmail" class="nd-input" type="email" placeholder="email" autocomplete="username"></div>
<div class="field"><input id="accPass" class="nd-input" type="password" placeholder="password" autocomplete="current-password"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<button class="btn-start" style="height:38px;font-size:13px" id="accIn">Sign In</button>
<button class="btn-stop" style="height:38px;font-size:13px" id="accUp">Create Account</button>
</div>
</div>
<div id="acctIn" style="display:none">
<div class="acct-line">
<span class="acct-ok">✓ Signed in as <b id="acctMail"></b></span>
<button class="link-btn" id="accOut2">Sign out</button>
</div>
<p class="helper" style="margin-top:8px">This PC shows under “My computers” on the website.</p>
<div class="fleet">
<div class="fleet-head"><span>LINKED DEVICES IN “MY COMPUTERS”</span>
<button class="link-btn" style="font-size:11px" id="fleetAll">View all</button></div>
<div class="fleet-grid" id="fleetGrid"></div>
</div>
</div>
</section>
</div>
</div>
<div class="startrow">
<button class="btn-start" id="startBtn"><svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M12 3L3 12.6H21L12 3Z" fill="#fff"/><path d="M8.6 14.4H15.4L16.5 20.5H7.5L8.6 14.4Z" fill="#fff"/></svg><span id="startTxt">Start Service</span></button>
<button class="btn-stop" id="stopBtn"><span class="stop-sq"></span><span>Stop</span></button>
</div>
<section class="card togglecard">
<div class="toggles">
<div class="tg"><button class="sw off" id="swSvc" role="switch"><span class="sw-thumb"></span></button><span class="tg-lb">Service<br>Installed</span></div>
<div class="tg"><button class="sw off" id="swWin" role="switch"><span class="sw-thumb"></span></button><span class="tg-lb">Start with<br>Windows</span></div>
<div class="tg"><button class="sw off" id="swPriv" role="switch"><span class="sw-thumb"></span></button><span class="tg-lb">Privacy<br>Mode</span></div>
<div class="tg"><button class="sw off" id="swLock" role="switch"><span class="sw-thumb"></span></button><span class="tg-lb">Lock on<br>disconnect</span></div>
<div class="tg"><button class="sw on" id="swTray" role="switch"><span class="sw-thumb"></span></button><span class="tg-lb">Close to<br>tray</span></div>
</div>
</section>
<div class="footrow">
<button class="btn-foot" id="webBtn"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>Open Web Viewer</button>
<button class="btn-foot" id="logsBtn"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>Logs Folder</button>
</div>
</div>
<!-- MY COMPUTERS VIEW -->
<div class="view" id="view-devs">
<section class="card">
<div class="card-head"><h2>My Computers</h2>
<span style="display:flex;gap:10px;align-items:center">
<button class="gr-link" id="devRefresh">↻ Refresh</button>
<button class="cb-go" id="enrollBtn" style="height:30px">+ Enroll Computer</button>
</span></div>
<div class="dev-toolbar">
<input class="dev-search" id="devSearch" type="text" placeholder="Search by name or ID…" spellcheck="false">
<span class="chips">
<button class="chip active" data-f="all">All</button>
<button class="chip" data-f="online">Online</button>
<button class="chip" data-f="offline">Offline</button>
</span>
</div>
<div id="devList"><p class="helper">Sign in to your NetDesk account to see the computers linked to it.</p></div>
</section>
</div>
<!-- SECURITY VIEW -->
<div class="view" id="view-sec">
<section class="card" style="margin-bottom:14px">
<div class="card-head"><h2>Two-Factor Authentication (TOTP)</h2><span class="badge-2fa" id="secTfaState">2FA: off</span></div>
<p class="helper" style="margin-top:0">Adds a 6-digit authenticator code (Google Authenticator, Authy, 1Password…)
required at every connect — even with the correct password.</p>
<div style="margin-top:12px;display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
<button class="btn-start" style="height:38px;font-size:13px;max-width:280px" id="secTfaBtn">Enable 2FA</button>
<div id="qrWrap" style="display:none;align-items:center;gap:14px">
<img id="qrImg" alt="TOTP QR" width="132" height="132"
style="border-radius:10px;background:#fff;padding:4px">
<div style="min-width:0">
<div class="lbl" style="margin-bottom:4px">Scan with your authenticator app</div>
<div class="mono" id="qrSecret" style="font-size:11px;color:var(--faint);word-break:break-all;max-width:320px"></div>
</div>
</div>
</div>
</section>
<section class="card" style="margin-bottom:14px">
<div class="card-head"><h2>Viewer IP Allowlist</h2><span class="tag-sm tag-em" id="allowState">off — everyone with the password</span></div>
<p class="helper" style="margin-top:0">Only these IPs / CIDR ranges may connect (e.g. <span class="mono">203.0.113.7, 10.0.0.0/8</span>).
Leave empty to allow any IP that knows the password. Applies to the next connection.</p>
<div style="display:flex;gap:10px;margin-top:10px;flex-wrap:wrap">
<input class="dev-search mono" id="allowIn" style="flex:1;min-width:260px" type="text"
placeholder="empty — all IPs allowed" spellcheck="false">
<button class="cb-go" id="allowSave" style="height:38px">Save Allowlist</button>
</div>
<p class="helper" style="margin-top:10px"><button class="gr-link" id="reqPreview">Preview the incoming-connection dialog →</button></p>
</section>
<section class="card">
<div class="card-head"><h2>Boot-Time Service</h2><span class="tag-sm tag-em" id="secSvcState">not installed</span></div>
<p class="helper" style="margin-top:0">Installs a SYSTEM service so this PC is reachable at the logon screen,
across reboots and UAC prompts — no one needs to be logged in. Requires one UAC approval.</p>
<div style="margin-top:12px;display:flex;gap:10px;flex-wrap:wrap">
<button class="btn-start" style="height:38px;font-size:13px;min-width:200px" id="secSvcBtn">Install Service</button>
<button class="btn-stop" style="height:38px;font-size:13px;min-width:160px" id="secSvcRm">Remove Service</button>
</div>
</section>
</div>
<!-- LOGS VIEW -->
<div class="view" id="view-logs">
<section class="card">
<div class="card-head"><h2>Session Audit Log</h2>
<span style="display:flex;gap:12px;align-items:center">
<span class="chips">
<button class="chip lchip active" data-lf="all">All</button>
<button class="chip lchip" data-lf="session">Sessions</button>
<button class="chip lchip" data-lf="error">Errors</button>
</span>
<button class="gr-link" id="logRefresh">↻ Refresh</button>
<button class="gr-link" id="logExport">⇩ Export</button>
<button class="gr-link" id="logOpen">Open folder</button></span></div>
<div class="logbox" id="logBox">Loading…</div>
</section>
</div>
</main>
</div>
<!-- modal -->
<div class="modal-bg" id="modalBg">
<div class="modal">
<div class="modal-h"><h3 id="mTitle">NetDesk</h3><button class="modal-x" id="mX">✕</button></div>
<div class="modal-b" id="mBody"></div>
<div class="modal-f"><button class="modal-ok" id="mOk">Close</button></div>
</div>
</div>
<div id="toasts"></div>
</div>
<script>
(function () {
"use strict";
window.__errs = [];
window.onerror = function (m, s, l, c) { window.__errs.push(m + " @" + l + ":" + c);
try { window.pywebview.api.log_err(m + " @" + l + ":" + c); } catch (e) {} };
function api() { return window.pywebview && window.pywebview.api; }
function call(m) {
var args = [].slice.call(arguments, 1);
var run = function () { var a = api(); if (!a) throw new Error("bridge"); return a[m].apply(a, args); };
return Promise.resolve().then(run).catch(function (e) {
return new Promise(function (r) { setTimeout(r, 700); }).then(function () { return run(); });
}).catch(function (e) { window.__errs.push(m + ": " + e); throw e; });
}
var $ = function (id) { return document.getElementById(id); };
/* ---------- modal + toasts ---------- */
function openModal(title, html, okLabel, onOk) {
$("mTitle").textContent = title; $("mBody").innerHTML = html;
$("mOk").textContent = okLabel || "Close";
$("mOk").onclick = function () { if (onOk && onOk() === false) return; closeModal(); };
$("modalBg").classList.add("open");
}
function closeModal() { $("modalBg").classList.remove("open"); }
$("mX").onclick = closeModal;
$("modalBg").addEventListener("click", function (e) { if (e.target === this) closeModal(); });
function toast(title, sub) {
var t = document.createElement("div"); t.className = "toast";
var b = document.createElement("b"); b.textContent = title;
var s = document.createElement("span"); s.textContent = sub || "";
t.append(b, s); $("toasts").append(t);
setTimeout(function () { t.style.opacity = "0"; t.style.transform = "translateY(6px)";
setTimeout(function () { t.remove(); }, 250); }, 2600);
}
/* ---------- auto-fit: the layout is designed at ~1080px; zoom the page so
it always fits the window with NO horizontal scroll, at any size ------- */
function fitZoom() {
var z = Math.min(1, window.innerWidth / 1080, window.innerHeight / 850);
z = Math.max(0.72, z);
document.documentElement.style.zoom = z;
// vh units are NOT scaled by CSS zoom - size the shell to the real window
var sh = document.querySelector(".shell");
if (sh) sh.style.height = Math.round(window.innerHeight / z) + "px";
window.scrollTo(0, 0);
}
window.addEventListener("resize", fitZoom); fitZoom();
/* ---------- window controls ---------- */
$("winMin").onclick = function () { call("minimize"); };
$("winMax").onclick = function () { call("maximize_window"); };
$("winClose").onclick = function () { call("close_request"); };
/* ---------- sidebar views ---------- */
var navBtns = document.querySelectorAll(".nav-item");
navBtns.forEach(function (b) {
b.onclick = function () {
navBtns.forEach(function (x) { x.classList.remove("active"); });
b.classList.add("active");
document.querySelectorAll(".view").forEach(function (v) { v.classList.remove("active"); });
$("view-" + b.dataset.view).classList.add("active");
if (b.dataset.view === "logs") loadLogs();
if (b.dataset.view === "devs") loadDevs();
};
});
/* ---------- state paint ---------- */
var S = { id: "", spaced: true, busySvc: false, acct: "" };
function fmtId(id) { return S.spaced ? String(id).replace(/(\d{3})(\d{3})(\d{3})/, "$1 $2 $3") : String(id); }
function setSwitch(id, on) { var b = $(id); b.classList.toggle("on", !!on); b.classList.toggle("off", !on);
b.setAttribute("aria-checked", String(!!on)); }
function paint(s) {
S.id = s.id || "";
$("bigId").textContent = fmtId(S.id);
if (document.activeElement !== $("srvIn")) $("srvIn").value = s.server || "";
if (document.activeElement !== $("regIn")) $("regIn").value = s.regKey || "";
if (document.activeElement !== $("nameIn")) $("nameIn").value = s.name || "";
if (document.activeElement !== $("pwInput") && s.password) $("pwInput").value = s.password;
try { $("relayHost").textContent = new URL((s.server || "").replace("wss","https").replace("ws","http")).host || "—"; }
catch (e) { $("relayHost").textContent = "—"; }
$("sideHost").textContent = s.name || "—";
var st = s.state === "online" && s.inSession ? "session" : s.state;
var pill = $("statusPill"), txt = $("statusTxt"), sideS = $("sideStatus");
if (st === "online" || st === "running" || st === "session") {
pill.className = "status-pill sp-running"; txt.textContent = st === "session" ? "In Session" : "Online";
sideS.textContent = "● Online"; sideS.style.color = "var(--em-br)";
$("startTxt").textContent = "Service Running";
} else if (st === "connecting") {
pill.className = "status-pill sp-conn"; txt.textContent = "Connecting…";
sideS.textContent = "● Connecting"; sideS.style.color = "#d9b96a";
$("startTxt").textContent = "Start Service";
} else {
pill.className = "status-pill sp-stopped"; txt.textContent = "Stopped";
sideS.textContent = "● Stopped"; sideS.style.color = "var(--crimson-fg)";
$("startTxt").textContent = "Start Service";
}
setSwitch("swSvc", s.serviceInstalled); setSwitch("swWin", s.startup);
setSwitch("swPriv", s.privacy); setSwitch("swLock", s.lockOnEnd);
setSwitch("swTray", s.closeToTray !== false);
var tfa = s.totp ? "2FA: ON" : "2FA: off";
$("twoFa").textContent = tfa; $("twoFa").classList.toggle("on", !!s.totp);
$("secTfaState").textContent = tfa; $("secTfaState").classList.toggle("on", !!s.totp);
$("secTfaBtn").textContent = s.totp ? "Disable 2FA" : "Enable 2FA";
$("nav2fa").textContent = s.totp ? "2FA On" : "2FA Off";
$("nav2fa").className = "nav-badge " + (s.totp ? "nb-em" : "nb-red");
$("secSvcState").textContent = s.serviceInstalled ? "installed" : "not installed";
// security view: TOTP QR + allowlist
if (s.totp && !S.qrLoaded) {
S.qrLoaded = true; // in-flight guard; cleared on failure so it retries
call("totp_qr").then(function (r) {
if (r.ok) { $("qrImg").src = r.png; $("qrSecret").textContent = r.secret;
$("qrWrap").style.display = "flex"; }
else S.qrLoaded = false;
}).catch(function () { S.qrLoaded = false; });
} else if (!s.totp) { S.qrLoaded = false; $("qrWrap").style.display = "none"; }
if (document.activeElement !== $("allowIn") && s.allowIps !== undefined)
$("allowIn").value = s.allowIps;
var hasAllow = !!(s.allowIps || "").trim();
$("allowState").textContent = hasAllow ? "on — restricted" : "off — everyone with the password";
// account
S.acct = s.account || "";
$("acctOut").style.display = S.acct ? "none" : "block";
$("acctIn").style.display = S.acct ? "block" : "none";
$("acctBadge").style.display = S.acct ? "inline-block" : "none";
if (S.acct) {
$("acctMail").textContent = S.acct;
$("sideAcct").style.display = "flex"; $("sideMail").textContent = S.acct;
$("sideAva").textContent = S.acct.slice(0, 2).toUpperCase();
} else { $("sideAcct").style.display = "none"; }
}
function refresh() { return call("get_state").then(paint).catch(function () {}); }
function poll() { refresh(); setTimeout(poll, 1500); }
/* ---------- ID card ---------- */
$("fmtBtn").onclick = function () { S.spaced = !S.spaced; $("bigId").textContent = fmtId(S.id); };
$("copyBtn").onclick = function () {
call("copy_id").then(function () { $("copyLbl").textContent = "Copied!"; toast("ID copied", S.id);
setTimeout(function () { $("copyLbl").textContent = "Copy ID"; }, 1400); });
};
/* ---------- password ---------- */
$("pwEye").onclick = function () { var i = $("pwInput"); i.type = i.type === "password" ? "text" : "password"; };
$("pwGen").onclick = function () {
openModal("Generate new password",
"Generate a strong random password?<br>The current one stops working for new connections.",
"Generate", function () {
call("generate_password").then(function (r) {
if (r.ok) { $("pwInput").type = "text"; $("pwInput").value = r.password;
openModal("New password", 'Your new unattended password:<br><span class="mono-box" style="font-size:17px;letter-spacing:1px">' +
r.password + "</span><br><br>It applies to the next incoming connection — no restart needed.");
} else openModal("Generate", r.error || "Failed.");
}); return false; });
};
$("pwSet").onclick = function () {
var v = $("pwInput").value;
if (!v) { $("pwInput").focus(); return; }
call("set_password", v).then(function (r) {
if (r.ok) toast("Password saved", "Applies to the next connection");
else openModal("Password", r.error || "Failed.");
});
};
function toggle2fa() {
call("toggle_2fa").then(function (r) {
if (!r.ok) return;
refresh();
if (r.on) openModal("2FA enabled",
'Add this secret to your authenticator app:<br><span class="mono-box">' + r.secret +
"</span><br><br>Also saved to:<br><span class=\"mono-box\">" + r.path + "</span>");
else toast("2FA disabled", "");
});
}
$("twoFa").onclick = toggle2fa; $("go2fa").onclick = toggle2fa; $("secTfaBtn").onclick = toggle2fa;
/* ---------- connection ---------- */
S.pingFails = 0;
function doPing() {
call("ping_relay").then(function (r) {
if (r.ok) S.pingFails = 0; else S.pingFails++;
// only declare offline after 2 consecutive failures (slow-line friendly)
var off = !r.ok && S.pingFails >= 2;
if (r.ok) { $("pingMs").textContent = r.ms + " ms"; $("pingBtn").textContent = "Ping Relay (" + r.ms + " ms)"; }
else if (off) $("pingMs").textContent = "offline";
$("relayDot").style.background = off ? "var(--crimson-fg)" : "var(--em)";
}).catch(function () {});
}
$("pingBtn").onclick = function () { doPing(); toast("Pinging relay…", ""); };
/* ---------- partner connect ---------- */
function goPartner() {
var v = $("partnerId").value;
call("open_partner", v).then(function (r) {
if (r.ok) toast("Opening session…", r.app ? "Remote screen opening in a NetDesk window" : "Opening the viewer in your browser");
else openModal("Connect to Partner", r.error || "Failed.");
});
}
$("partnerGo").onclick = goPartner;
$("partnerId").addEventListener("keydown", function (e) { if (e.key === "Enter") goPartner(); });
/* ---------- account ---------- */
$("accIn").onclick = function () {
call("account_login", $("accEmail").value, $("accPass").value).then(function (r) {
if (r.ok) { $("accPass").value = ""; toast("Signed in", r.email); refresh(); loadDevs(); }
else openModal("Sign in", r.error || "Failed.");
});
};
$("accUp").onclick = function () {
call("account_signup", $("accEmail").value, $("accPass").value).then(function (r) {
if (r.ok) { $("accPass").value = ""; refresh(); loadDevs();
openModal("Account created", "You are signed in. This PC now appears under “My computers”."); }
else openModal("Create account", r.error || "Failed.");
});
};
$("accPass").addEventListener("keydown", function (e) { if (e.key === "Enter") $("accIn").click(); });
$("accOut2").onclick = function () { call("account_logout").then(function () { refresh(); }); };
$("fleetAll").onclick = function () { document.querySelector('[data-view="devs"]').click(); };
/* fleet cache + filters */
S.devs = []; S.devFilter = "all"; S.devQ = "";
function devMatch(d) {
if (S.devFilter === "online" && !d.online) return false;
if (S.devFilter === "offline" && d.online) return false;
var q = S.devQ.toLowerCase();
return !q || (d.name || "").toLowerCase().indexOf(q) >= 0 || String(d.id).indexOf(q) >= 0;
}
function devRow(d, compact) {
var el = document.createElement("div");
el.className = compact ? "fleet-card" + (d.online ? " click" : "") : "list-row";
var left = document.createElement("div"); left.style.minWidth = "0";
var n = document.createElement("div"); n.className = "fleet-name"; n.textContent = d.name || d.id;
var s = document.createElement("div"); s.className = "fleet-sub" + (d.online ? " on" : "");
s.textContent = d.online ? (d.busy ? "In session" : "Online") : "Offline";
left.append(n, s);
el.append(left);
if (compact) {
var dot = document.createElement("span"); dot.className = "fdot" + (d.online ? " on" : ""); el.append(dot);
if (d.online) el.onclick = function () { call("open_partner", d.id); };
} else {
var right = document.createElement("div"); right.style.display = "flex"; right.style.gap = "8px"; right.style.alignItems = "center";
var idm = document.createElement("span"); idm.className = "mono"; idm.style.color = "var(--faint)"; idm.style.fontSize = "12px";
idm.textContent = d.id; right.append(idm);
if (d.online) { var go = document.createElement("button"); go.className = "cb-go"; go.textContent = "Connect";
go.onclick = function () { call("open_partner", d.id); }; right.append(go); }
else if (d.mac) { var wk = document.createElement("button"); wk.className = "wake-btn"; wk.textContent = "⚡ Wake";
wk.title = "Wake-on-LAN via another online computer on your account";
wk.onclick = function () {
wk.disabled = true; wk.textContent = "Waking…";
call("wake_device", d.id).then(function (r) {
wk.disabled = false; wk.textContent = "⚡ Wake";
if (r.ok) toast(r.already_online ? "Already online" : "Wake packet sent",
r.already_online ? "" : "Relayed via " + (r.relayed || 1) + " computer(s) on your LAN");
else openModal("Wake-on-LAN", r.error || "Failed.");
});
}; right.append(wk); }
el.append(right);
}
return el;
}
function renderDevs() {
var full = $("devList"); full.innerHTML = "";
var grid = $("fleetGrid"); grid.innerHTML = "";
var list = S.devs.filter(devMatch);
if (!list.length) {
var q = document.createElement("p"); q.className = "helper";
q.textContent = S.devs.length ? "No computers match this filter." :
"No computers linked yet — this PC appears here once the engine registers.";
full.append(q);
}
list.forEach(function (d) { full.append(devRow(d, false)); });
S.devs.slice(0, 3).forEach(function (d) { grid.append(devRow(d, true)); });
}
function loadDevs() {
call("list_devices").then(function (r) {
$("navDevCount").textContent = String((r.devices || []).length);
if (!r.ok) {
var full = $("devList"); full.innerHTML = "";
var p = document.createElement("p"); p.className = "helper";
p.textContent = r.error === "not signed in" ?
"Sign in to your NetDesk account to see the computers linked to it." :
"Could not load devices: " + (r.error || "");
full.append(p); return;
}
S.devs = r.devices || [];
renderDevs();
}).catch(function () {});
}
$("devRefresh").onclick = loadDevs;
$("devSearch").addEventListener("input", function () { S.devQ = this.value.trim(); renderDevs(); });
document.querySelectorAll(".chip[data-f]").forEach(function (c) {
c.onclick = function () {
document.querySelectorAll(".chip[data-f]").forEach(function (x) { x.classList.remove("active"); });
c.classList.add("active"); S.devFilter = c.dataset.f; renderDevs();
};
});
$("enrollBtn").onclick = function () {
var base = "https://netdesk.space";
try { base = new URL($("srvIn").value.replace("wss", "https").replace("ws", "http")).origin; } catch (e) {}
openModal("Enroll a new computer",
"<b>1.</b> On the new PC, download the agent:<br><span class=\"mono-box\">" + base + "/download</span><br><br>" +
"<b>2.</b> Run <span class=\"mono\">NetDeskHost.exe</span> — it gets its own 9-digit ID automatically.<br><br>" +
"<b>3.</b> Sign in with the same account (<span class=\"mono\">" + (S.acct || "your account") + "</span>) " +
"in its NetDesk Account card — the computer then appears here in My Computers.<br><br>" +
"<b>4.</b> Optional: turn on <i>Service Installed</i> there for unattended access at boot.");
};
/* ---------- start / stop ---------- */
$("startBtn").onclick = function () {
call("start_engine", $("srvIn").value, $("regIn").value, $("nameIn").value).then(function (r) {
if (r.ok) toast("Engine starting…", "Connecting to the relay");
else openModal("Start", r.error || "Failed.");
});
};
$("stopBtn").onclick = function () { call("stop_engine").then(function () { toast("Engine stopped", ""); refresh(); }); };
/* ---------- toggles ---------- */
function installService() {
if (S.busySvc) return; S.busySvc = true;
var done = function () { S.busySvc = false; refresh(); };
if ($("swSvc").classList.contains("on")) {
call("remove_service").then(function (r) { toast("Service removed", ""); }).then(done, done);
return;
}
openModal("Installing service",
"Accept the UAC prompt to continue. The service makes this PC reachable at boot, " +
"at the logon screen and across UAC prompts.");
call("install_service").then(function (r) {
if (r.ok) openModal("Service installed", (r.msg || "Installed.") +
"<br><br>Your PC is now reachable even after a reboot — before anyone logs in.");
else openModal("Install service", r.error || "Install failed.");
}).then(done, done);
}
$("swSvc").onclick = installService; $("secSvcBtn").onclick = installService;
$("secSvcRm").onclick = function () { call("remove_service").then(function () { toast("Service removed", ""); refresh(); }); };
$("swWin").onclick = function () {
var on = $("swWin").classList.contains("on");
call(on ? "remove_startup" : "add_startup").then(function () { refresh(); });
};
$("swPriv").onclick = function () {
var on = !$("swPriv").classList.contains("on");
setSwitch("swPriv", on);
if (on) openModal("Privacy mode", "Privacy mode turns the PHYSICAL monitor off while someone is " +
"connected. Anyone at the PC sees a no-signal screen. Only enable this if that is what you want.");
call("set_switches", { privacy: on });
};
$("swLock").onclick = function () {
var on = !$("swLock").classList.contains("on");
setSwitch("swLock", on); call("set_switches", { lock: on });
};
$("swTray").onclick = function () {
var on = !$("swTray").classList.contains("on");
setSwitch("swTray", on); call("set_close_to_tray", on);
};
/* ---------- footer / misc ---------- */
$("webBtn").onclick = function () { call("open_viewer"); };
$("logsBtn").onclick = function () { call("open_logs"); };
$("logOpen").onclick = function () { call("open_logs"); };
$("portableBtn").onclick = function () {
call("get_state").then(function (s) {
openModal("Portable mode", 'Configuration is stored next to the app:<br><span class="mono-box">' +
(s.configPath || "") + "</span>");
});
};
/* ---------- security extras ---------- */
$("allowSave").onclick = function () {
call("set_allow_ips", $("allowIn").value).then(function (r) {
if (r.ok) { toast("Allowlist saved", r.count ? r.count + " entr" + (r.count > 1 ? "ies" : "y") :
"cleared — all IPs allowed"); refresh(); }
else openModal("IP allowlist", r.error || "Failed.");
});
};
$("allowIn").addEventListener("keydown", function (e) { if (e.key === "Enter") $("allowSave").click(); });
$("reqPreview").onclick = function () {
openModal("Incoming connection request",
'<div style="text-align:center;padding:6px 0">' +
'<div style="font-size:34px;margin-bottom:8px">🖥️</div>' +
'<b style="font-size:15px">203.0.113.7 wants to control this desktop</b><br>' +
'<span style="color:var(--faint);font-size:12px">Password verified · E2E key exchanged · ' +
new Date().toLocaleTimeString() + "</span><br><br>" +
'<span style="color:#94a3b8;font-size:12px">This is a preview of what a viewer sees while connecting. ' +
"Access is only granted after the unattended password (and 2FA, when on) is verified.</span></div>",
"Got it");
};
/* ---------- logs view ---------- */
S.logLines = []; S.logFilter = "all";
function logMatch(l) {
var s = l.toLowerCase();
if (S.logFilter === "session") return s.indexOf("session") >= 0 || s.indexOf("viewer") >= 0 || s.indexOf("connect") >= 0;
if (S.logFilter === "error") return s.indexOf("error") >= 0 || s.indexOf("warn") >= 0 || s.indexOf("denied") >= 0 || s.indexOf("fail") >= 0;
return true;
}
function renderLogs() {
var lines = S.logLines.filter(logMatch);
$("logBox").textContent = lines.join("\n") || "No matching log entries.";
$("logBox").scrollTop = $("logBox").scrollHeight;
}
function loadLogs() {
call("get_logs", 300).then(function (r) {
S.logLines = r.lines || [];
$("navLogN").textContent = String(S.logLines.length);
renderLogs();
}).catch(function () {});
}
$("logRefresh").onclick = loadLogs;
document.querySelectorAll(".lchip").forEach(function (c) {
c.onclick = function () {
document.querySelectorAll(".lchip").forEach(function (x) { x.classList.remove("active"); });
c.classList.add("active"); S.logFilter = c.dataset.lf; renderLogs();
};
});
$("logExport").onclick = function () {
call("export_logs").then(function (r) {
if (r.ok) openModal("Log exported", 'Saved to:<br><span class="mono-box">' + r.path + "</span>");
else openModal("Export", r.error || "Failed.");
});
};
/* ---------- boot ---------- */
var ready = new Promise(function (res) {
if (api()) return res();
window.addEventListener("pywebviewready", res);
setTimeout(res, 4000);
});
ready.then(function () {
refresh().then(function () { poll(); loadDevs(); doPing(); });
setInterval(doPing, 12000);
setInterval(function () { if (S.acct) loadDevs(); }, 20000);
});
})();
</script>
</body>
</html>
+825
View File
@@ -0,0 +1,825 @@
#!/usr/bin/env python3
"""NetDesk desktop GUI - renders the approved HTML design in a native
WebView2 window (pywebview), wired to the real engine backend."""
import base64
import json
import os
import secrets
import string
import subprocess
import sys
import threading
import time
import urllib.request
import urllib.error
import webbrowser
from pathlib import Path
IS_WIN = sys.platform == "win32"
IS_MAC = sys.platform == "darwin"
if IS_WIN:
import ctypes
HERE = Path(__file__).resolve().parent
if getattr(sys, "frozen", False):
HERE = Path(sys.executable).resolve().parent
sys.path.insert(0, HERE)
else:
sys.path.insert(0, str(HERE))
import netdesk_host as nd # engine module
CREATE_NO_WINDOW = 0x08000000 if IS_WIN else 0
NL = chr(10)
def _html_path():
if getattr(sys, "frozen", False):
me = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable))
cand = os.path.join(me, "netdesk_gui.html")
if os.path.exists(cand):
return cand
return os.path.join(os.path.dirname(sys.executable), "netdesk_gui.html")
return str(HERE / "netdesk_gui.html")
def engine_exe():
if getattr(sys, "frozen", False):
return [sys.executable, "--engine"]
return [sys.executable, str(HERE / "netdesk_host.py"), "--engine"]
class Api:
"""Exposed to the HTML UI as window.pywebview.api.*"""
def __init__(self):
self.proc = None
self.cfg = nd.Config(nd.appdata_dir() / "config.json")
self.portable = getattr(sys, "frozen", False) and \
self.cfg.path.parent.resolve() == HERE
_svc_cache = {"v": None, "t": 0.0}
_stu_cache = {"v": None, "t": 0.0}
def _service_installed(self):
# NOTE: `schtasks /Query /TN NetDesk` returns "Access is denied" for a
# NON-elevated process when the task was created elevated - which made
# the toggle grey on every fresh app start. The COM API distinguishes
# not-found (0x80070002) from access-denied (0x80070005 = task EXISTS)
# by HRESULT, which is locale-independent.
if not IS_WIN:
return False
if time.time() - type(self)._svc_cache["t"] < 10:
return bool(type(self)._svc_cache["v"])
ps = ("$s=New-Object -ComObject Schedule.Service;$s.Connect();"
"try{$null=$s.GetFolder('\\').GetTask('NetDesk');Write-Output 1}"
"catch{if($_.Exception.HResult -eq -2147024891){Write-Output 1}"
"else{Write-Output 0}}")
try:
r = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
capture_output=True, text=True, timeout=20,
creationflags=CREATE_NO_WINDOW)
v = (r.stdout or "").strip().endswith("1")
except Exception:
v = bool(type(self)._svc_cache["v"]) # keep last known on error
type(self)._svc_cache = {"v": v, "t": time.time()}
return v
def _startup_present(self):
if not IS_WIN:
return False
if time.time() - type(self)._stu_cache["t"] < 10:
return bool(type(self)._stu_cache["v"])
r = subprocess.run(["reg", "query",
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
"/v", "NetDesk"], capture_output=True,
creationflags=CREATE_NO_WINDOW).returncode
type(self)._stu_cache = {"v": r == 0, "t": time.time()}
return r == 0
def _engine_alive(self):
sf = self.cfg.path.parent / "gui_status.json"
try:
d = json.loads(sf.read_text("utf-8"))
return time.time() - d.get("ts", 0) < 12
except Exception:
return False
def log_err(self, msg):
try:
with open(self.cfg.path.parent / "gui_err.log", "a", encoding="utf-8") as f:
f.write(time.strftime("%H:%M:%S ") + str(msg) + NL)
except Exception:
pass
return {"ok": True}
# ---------------- account (sign in / sign up, binds this PC to the web app)
def _api_base(self):
url = self.cfg.data.get("server") or nd.DEFAULT_SERVER
url = url.replace("wss://", "https://").replace("ws://", "http://")
url = url.rsplit("/ws", 1)[0]
return url.rstrip("/")
def _api_post(self, path, payload):
data = json.dumps(payload).encode()
headers = {"Content-Type": "application/json"}
tok = self.cfg.data.get("account_token", "")
if tok:
headers["Authorization"] = "Bearer " + tok
req = urllib.request.Request(self._api_base() + path, data=data,
headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode() or "{}")
except Exception:
return {"error": f"server error {e.code}"}
except Exception as e:
return {"error": f"cannot reach server: {e}"}
def _account_result(self, r):
if r.get("ok") and r.get("token"):
self.cfg.data["account_token"] = r["token"]
self.cfg.data["account_email"] = r.get("email", "")
self.cfg.save() # engine picks it up at the next (re)register via reload()
return {"ok": True, "email": r.get("email", ""), "msg": r.get("msg", "")}
return {"ok": False, "error": r.get("error", "sign-in failed")}
def account_login(self, email, password):
email = (email or "").strip().lower()
if "@" not in email or len(password or "") < 1:
return {"ok": False, "error": "Enter your email and password."}
return self._account_result(self._api_post(
"/api/auth/login", {"email": email, "password": password, "app": True}))
def account_signup(self, email, password):
email = (email or "").strip().lower()
if "@" not in email:
return {"ok": False, "error": "Enter a valid email."}
if len(password or "") < 8:
return {"ok": False, "error": "Password needs 8+ characters."}
return self._account_result(self._api_post(
"/api/auth/signup", {"email": email, "password": password, "app": True}))
def account_logout(self):
tok = self.cfg.data.get("account_token", "")
if tok:
try:
self._api_post("/api/auth/logout", {})
except Exception:
pass
self.cfg.data.pop("account_token", None)
self.cfg.data.pop("account_email", None)
self.cfg.save()
return {"ok": True}
def get_state(self):
try:
return self._get_state_inner()
except Exception as e:
try:
self.log_err("get_state: " + repr(e))
except Exception:
pass
return {"id": self.cfg.id, "state": "error", "error": repr(e),
"portable": self.portable}
_last_online = 0.0
def _get_state_inner(self):
state, in_sess = "stopped", False
sf = self.cfg.path.parent / "gui_status.json"
try:
d = json.loads(sf.read_text("utf-8"))
if time.time() - d.get("ts", 0) < 12:
state, in_sess = d.get("state"), bool(d.get("in_session"))
except Exception:
pass
# Anti-flicker: a stale duplicate engine writes "error: already
# connected elsewhere" between the real engine's online heartbeats.
# That error literally means the ID IS being served by this machine,
# and any brief error/connecting blip within 12s of a confirmed
# online heartbeat is noise, not a real outage.
now = time.time()
cls = type(self)
alive = self._engine_alive() # some local engine heartbeated < 12s ago
# A stale duplicate engine writes "already connected elsewhere" between
# the real engine's online heartbeats. When an engine is genuinely
# heartbeating locally, that means the ID IS served here -> online.
if isinstance(state, str) and "already connected" in state and alive:
state = "online"
if state in ("online", "session"):
cls._last_online = now
elif (state != "stopped" or alive) and now - cls._last_online < 12:
# brief connecting/error blip within 12s of a confirmed online
# (e.g. slow-network reconnect) is noise, not a real outage
state = "online"
# Truly stopped only when OUR spawned engine exited AND no engine at all
# is heartbeating - never override a live/reconnecting engine's status.
if self.proc is not None and self.proc.poll() is not None \
and not alive and now - cls._last_online >= 12:
state = "stopped"
elif state is None:
state = "running" if alive else "stopped"
return {
"id": self.cfg.id,
"server": self.cfg.data.get("server") or nd.DEFAULT_SERVER,
"regKey": self.cfg.data.get("reg_key", ""),
"name": self.cfg.data.get("name", ""),
"password": self.cfg.data.get("pw_plain", ""),
"account": self.cfg.data.get("account_email", ""),
"state": state,
"inSession": in_sess,
"totp": bool(self.cfg.data.get("totp")),
"allowIps": self.cfg.data.get("allow_ips", ""),
"portable": self.portable,
"configPath": str(self.cfg.path),
"serviceInstalled": self._service_installed(),
"startup": self._startup_present(),
"privacy": bool(self.cfg.data.get("privacy_on_session")
and self.cfg.data.get("allow_display_power")),
"lockOnEnd": bool(self.cfg.data.get("lock_on_end")),
"closeToTray": bool(self.cfg.data.get("close_to_tray", True)),
"inTray": self._tray_running(),
"isMac": IS_MAC,
}
def copy_id(self):
if IS_WIN:
subprocess.run(["powershell", "-Command",
f"Set-Clipboard -Value '{self.cfg.id}'"],
capture_output=True, creationflags=CREATE_NO_WINDOW)
return {"ok": True}
def start_engine(self, server, regkey, name):
if self._engine_alive():
return {"ok": False, "error": "Engine is already running."}
server = nd.normalize_server(server) or (self.cfg.data.get("server") or nd.DEFAULT_SERVER)
self.cfg.data["server"] = server
self.cfg.data["server_locked_by_user"] = True
if regkey.strip():
self.cfg.data["reg_key"] = regkey.strip()
if name.strip():
self.cfg.data["name"] = name.strip()
self.cfg.save()
cmd = engine_exe() + ["--server", server]
regkey = regkey.strip() or self.cfg.data.get("reg_key", "")
if regkey:
cmd += ["--reg-key", regkey]
if name.strip():
cmd += ["--name", name.strip()]
flags = CREATE_NO_WINDOW if IS_WIN else 0
self.proc = subprocess.Popen(cmd, creationflags=flags,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"ok": True}
def ensure_engine(self):
"""Auto-bring the host ONLINE on app launch (TeamViewer-style) unless
the user chose to stay offline. Safe: a fresh config always carries a
random password, so 'online' never means 'open to anyone'. No-op when
an engine (user or SYSTEM broker) is already serving this ID."""
try:
if self.cfg.data.get("start_offline"):
return {"ok": False, "offline": True}
if self._engine_alive():
return {"ok": True, "already": True}
server = self.cfg.data.get("server") or nd.DEFAULT_SERVER
cmd = engine_exe() + ["--server", server]
rk = self.cfg.data.get("reg_key", "")
if rk:
cmd += ["--reg-key", rk]
nm = self.cfg.data.get("name", "")
if nm:
cmd += ["--name", nm]
flags = CREATE_NO_WINDOW if IS_WIN else 0
self.proc = subprocess.Popen(cmd, creationflags=flags,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
return {"ok": True, "started": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def set_start_offline(self, on):
"""User preference: skip auto-going-online when the app launches."""
self.cfg.data["start_offline"] = bool(on)
self.cfg.save()
return {"ok": True}
def stop_engine(self):
if self.proc is not None and self.proc.poll() is None:
self.proc.terminate()
self.proc = None
return {"ok": True}
if IS_WIN and getattr(sys, "frozen", False):
subprocess.run(["powershell", "-Command",
"Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | "
"Where-Object { $_.CommandLine -like '*--engine*' } | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"],
capture_output=True, creationflags=CREATE_NO_WINDOW)
return {"ok": True}
return {"ok": False, "error": "not running"}
def set_password(self, pw):
pw = (pw or "").strip()
if len(pw) < 8:
return {"ok": False, "error": "Use at least 8 characters."}
self.cfg.set_password(pw)
return {"ok": True, "msg": "Password updated - it applies to the next connection."}
def generate_password(self):
"""One-click random password, TeamViewer/RustDesk style. Saved and
shown; the engine picks it up at the next incoming connection."""
pw = nd.gen_password(10)
self.cfg.set_password(pw)
return {"ok": True, "password": pw}
def toggle_2fa(self):
if self.cfg.data.get("totp"):
self.cfg.data.pop("totp", None)
self.cfg.save()
return {"ok": True, "on": False}
secret = "".join(secrets.choice(string.ascii_uppercase + "234567") for _ in range(32))
self.cfg.set_totp(secret)
f = self.cfg.path.parent / "totp_secret.txt"
f.write_text("NetDesk 2FA secret (base32)\n\n" + secret + "\n", "utf-8")
return {"ok": True, "on": True, "secret": secret, "path": str(f)}
def totp_qr(self):
"""QR code (base64 PNG) for the current TOTP secret - scan it with
Google Authenticator / Authy / 1Password."""
secret = self.cfg.data.get("totp", "")
if not secret:
return {"ok": False, "error": "2FA is not enabled"}
label = f"NetDesk:{self.cfg.id}"
uri = (f"otpauth://totp/{urllib.parse.quote(label)}?secret={secret}"
f"&issuer=NetDesk&digits=6&period=30")
try:
import io
import qrcode
img = qrcode.make(uri, box_size=5, border=2)
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
return {"ok": True, "png": "data:image/png;base64," + b64,
"secret": secret}
except Exception as e:
return {"ok": False, "error": str(e)[:120], "secret": secret}
def set_allow_ips(self, text):
"""Save the viewer IP/CIDR allowlist (empty = allow everyone)."""
import ipaddress
parts = str(text or "").replace(",", " ").split()
bad = []
for p in parts:
try:
ipaddress.ip_network(p, strict=False)
except ValueError:
bad.append(p)
if bad:
return {"ok": False, "error": "Not a valid IP/CIDR: " + ", ".join(bad[:3])}
self.cfg.data["allow_ips"] = " ".join(parts)
self.cfg.save()
return {"ok": True, "count": len(parts)}
def wake_device(self, dev_id):
"""Wake-on-LAN an offline fleet computer (relayed via the server)."""
tok = self.cfg.data.get("account_token", "")
if not tok:
return {"ok": False, "error": "not signed in"}
r = self._api_post("/api/wake", {"id": str(dev_id)})
if r.get("ok"):
return {"ok": True, "already_online": bool(r.get("already_online")),
"relayed": r.get("relayed", 0)}
return {"ok": False, "error": r.get("error", "wake failed")}
def export_logs(self):
"""Copy the session audit log to the Desktop for support/compliance."""
try:
src = self.cfg.path.parent / "host.log"
if not src.is_file():
return {"ok": False, "error": "no log file yet"}
dest_dir = Path.home() / "Desktop"
if not dest_dir.is_dir():
dest_dir = Path.home()
dest = dest_dir / time.strftime("netdesk-log-%Y%m%d-%H%M%S.txt")
dest.write_bytes(src.read_bytes())
return {"ok": True, "path": str(dest)}
except Exception as e:
return {"ok": False, "error": str(e)[:120]}
# ---------------- enterprise UI data feeds ----------------
def ping_relay(self):
"""Round-trip time to the configured relay's /health endpoint.
Retries once so a single dropped packet on a slow line never
flashes 'offline'."""
err = ""
for _ in range(2):
try:
t0 = time.time()
with urllib.request.urlopen(self._api_base() + "/health", timeout=10) as r:
r.read()
return {"ok": True, "ms": int((time.time() - t0) * 1000)}
except Exception as e:
err = str(e)[:120]
return {"ok": False, "error": err}
def list_devices(self):
"""This account's computers from the relay (for My Computers)."""
tok = self.cfg.data.get("account_token", "")
if not tok:
return {"ok": False, "error": "not signed in", "devices": []}
req = urllib.request.Request(self._api_base() + "/api/devices",
headers={"Authorization": "Bearer " + tok})
try:
with urllib.request.urlopen(req, timeout=10) as r:
d = json.loads(r.read().decode())
return {"ok": True, "devices": d.get("devices", [])}
except Exception as e:
return {"ok": False, "error": str(e)[:120], "devices": []}
def get_logs(self, n=150):
"""Tail of the local agent log for the Session Logs view."""
try:
p = self.cfg.path.parent / "host.log"
lines = p.read_text("utf-8", errors="replace").splitlines()[-int(n):]
return {"ok": True, "lines": lines}
except Exception as e:
return {"ok": False, "lines": [], "error": str(e)[:120]}
def open_partner(self, pid):
"""Outgoing connection: open the remote-control viewer in its own app
window (WebView2) - app-to-app like RustDesk/TeamViewer, one window per
partner. Falls back to the system browser only if the window can't open."""
digits = "".join(ch for ch in str(pid or "") if ch.isdigit())
if len(digits) != 9:
return {"ok": False, "error": "Enter the partner's 9-digit ID."}
url = self._api_base() + "/app?id=" + digits + "&embed=1"
try:
import webview
import ctypes
sw, sh = 1536, 864
try:
u = ctypes.windll.user32
try:
scale = u.GetDpiForSystem() / 96.0
except Exception:
scale = 1.0
sw = u.GetSystemMetrics(0) / (scale or 1.0)
sh = u.GetSystemMetrics(1) / (scale or 1.0)
except Exception:
pass
w, h = int(sw * 0.8), int(sh * 0.82)
title = "NetDesk – " + " ".join(digits[i:i + 3] for i in range(0, 9, 3))
# the viewer window has the normal OS frame (resizable, maximize)
webview.create_window(title, url, width=w, height=h, min_size=(720, 460),
x=max(0, int((sw - w) / 2)), y=max(0, int((sh - h) / 2)),
background_color="#05070a", text_select=False)
return {"ok": True, "app": True}
except Exception as e:
self.log_err("viewer window: %s" % e)
webbrowser.open(url)
return {"ok": True}
_maximized = False
def maximize_window(self):
"""Toggle maximize/restore (never fullscreen - that traps a frameless
window with no way out)."""
try:
import webview
for w in webview.windows:
try:
if type(self)._maximized:
w.restore()
type(self)._maximized = False
else:
w.maximize()
type(self)._maximized = True
except Exception:
pass
except Exception:
pass
return {"ok": True, "maximized": type(self)._maximized}
def fit_window(self, height):
"""Resize the window height to fit its content (width unchanged)."""
try:
import webview
h = max(560, min(int(height), 1400))
for w in webview.windows:
try:
w.resize(getattr(w, "width", 1024) or 1024, h)
except Exception:
pass
except Exception:
pass
return {"ok": True}
def set_switches(self, startup=None, privacy=None, lock=None):
if privacy is not None:
self.cfg.data["privacy_on_session"] = bool(privacy)
self.cfg.data["allow_display_power"] = bool(privacy)
if lock is not None:
self.cfg.data["lock_on_end"] = bool(lock)
self.cfg.save()
return {"ok": True}
def install_service(self):
if not IS_WIN:
return {"ok": False, "error": "Windows only"}
# hand over: stop the GUI-spawned engine; the service owns the connection
self.stop_engine()
server = self.cfg.data.get("server") or nd.DEFAULT_SERVER
# clear any stale result from a previous attempt so we read a fresh one
result_path = nd.programdata_dir() / "install_result.json"
try:
result_path.unlink()
except Exception:
pass
exe = sys.executable
params = f'--install --server "{server}"'
reg = self.cfg.data.get("reg_key", "")
if reg:
params += f' --reg-key "{reg}"'
if not getattr(sys, "frozen", False):
params = f'"{HERE / "netdesk_host.py"}" ' + params
try:
r = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, params, None, 1)
if r <= 32:
return {"ok": False, "error": "UAC was cancelled - accept the prompt to install."}
except Exception as e:
return {"ok": False, "error": str(e)}
# Wait up to 60 s: the elevated one-file exe must first self-extract
# (a few seconds) before it can create the task. Trust install_result.json
# (written by the elevated installer) as the definitive answer, and fall
# back to querying the scheduled task directly.
for _ in range(120):
try:
res = json.loads(result_path.read_text("utf-8"))
if res.get("ok"):
# force the status cache so the toggle turns green immediately
type(self)._svc_cache = {"v": True, "t": time.time()}
return {"ok": True,
"msg": "Service installed - reachable at boot, the logon screen "
"and UAC prompts. The engine handed over to the service."}
return {"ok": False, "error": res.get("error", "install failed")}
except Exception:
pass
if self._service_installed():
type(self)._svc_cache = {"v": True, "t": time.time()}
return {"ok": True,
"msg": "Service installed - reachable at boot, the logon screen "
"and UAC prompts."}
time.sleep(0.5)
return {"ok": False,
"error": "Install is taking longer than expected. If you accepted the UAC "
"prompt, the toggle should turn on shortly - reopen the app to check."}
def remove_service_full(self):
r = self.remove_service()
# also stop broker/agent processes from the removed service
if IS_WIN and getattr(sys, "frozen", False):
subprocess.run(["powershell", "-Command",
"Get-CimInstance Win32_Process -Filter \"Name='NetDeskHost.exe'\" | "
"Where-Object { $_.CommandLine -like '*--broker*' -or "
"$_.CommandLine -like '*--engine*' } | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"],
capture_output=True, creationflags=CREATE_NO_WINDOW)
return r
def remove_service(self):
if not IS_WIN:
return {"ok": False}
subprocess.run(["schtasks", "/Delete", "/TN", "NetDesk", "/F"],
capture_output=True, creationflags=CREATE_NO_WINDOW)
return {"ok": True, "msg": "Service removed (if it existed)."}
def add_startup(self):
if getattr(sys, "frozen", False):
exe = Path(sys.executable).resolve()
else:
exe = HERE / "NetDeskHost.exe"
val = f'"{exe}" --engine --server "{self.cfg.data.get("server") or "wss://netdesk.space/ws"}"'
subprocess.run(["reg", "add", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
"/v", "NetDesk", "/t", "REG_SZ", "/d", val, "/f"],
capture_output=True, creationflags=CREATE_NO_WINDOW)
return {"ok": True}
def remove_startup(self):
if not IS_WIN:
return {"ok": False}
subprocess.run(["reg", "delete", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
"/v", "NetDesk", "/f"], capture_output=True,
creationflags=CREATE_NO_WINDOW)
return {"ok": True, "msg": "Startup entry removed."}
def open_viewer(self):
url = self.cfg.data.get("server") or "wss://netdesk.space/ws"
url = url.replace("wss://", "https://").replace("ws://", "http://")
url = url.rsplit("/ws", 1)[0] or "http://127.0.0.1:8443"
webbrowser.open(url)
return {"ok": True}
def close_request(self):
"""✕ button: hide to tray by default, or really quit if disabled."""
if self.cfg.data.get("close_to_tray", True):
self.hide_window()
return {"ok": True, "hidden": True}
return {"ok": True, "hidden": False, "quit": True}
def hide_window(self):
try:
import webview
for w in webview.windows:
w.hide()
except Exception:
pass
def show_window(self):
try:
import webview
for w in webview.windows:
w.show()
except Exception:
pass
def minimize(self):
try:
import webview
for w in webview.windows:
w.minimize()
except Exception:
pass
return {"ok": True}
def set_close_to_tray(self, on):
self.cfg.data["close_to_tray"] = bool(on)
self.cfg.save()
return {"ok": True}
def quit_all(self):
try:
self.stop_engine()
except Exception:
pass
try:
import webview
for w in webview.windows:
w.destroy()
except Exception:
pass
os._exit(0)
def _tray_running(self):
return getattr(self, "_tray", None) is not None and self._tray.visible
def open_logs(self):
p = str(self.cfg.path.parent)
if IS_WIN:
os.startfile(p) # noqa
elif IS_MAC:
subprocess.Popen(["open", p])
else:
subprocess.Popen(["xdg-open", p])
return {"ok": True}
def _tray_image():
from PIL import Image as _I
return _I.open(_io.BytesIO(base64.b64decode(ICON_B64))) if ICON_B64 else None
def _start_tray(api):
"""System tray icon with the NetDesk logo and control menu."""
try:
import pystray
img = _tray_image()
if img is None:
return
import webview
def show_w(api_ref, icon_ref, item):
api.show_window()
def quit_all(icon_ref, item):
api.quit_all()
def noop(icon_ref, item):
pass
menu = pystray.Menu(
pystray.MenuItem("Show NetDesk", lambda ic, it: (api.show_window()),
default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Start engine",
lambda ic, it: api.start_engine(
api.cfg.data.get("server", ""),
"", api.cfg.data.get("name", ""))),
pystray.MenuItem("Stop engine", lambda ic, it: api.stop_engine()),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Open Web Viewer", lambda ic, it: api.open_viewer()),
pystray.MenuItem("Exit NetDesk", lambda ic, it: api.quit_all()),
)
icon = pystray.Icon("NetDesk", img, "NetDesk", menu)
api._tray = icon
icon.run_detached()
except Exception as e:
print("tray unavailable:", e)
def run():
import webview
api = Api()
# standard client size like RustDesk/TeamViewer - never starts maximized.
# Sized to ~62% x 75% of the desktop so it always feels like a windowed
# app; the page auto-zooms so the full layout fits (see fitZoom in HTML).
sw, sh = 1536, 864
try:
import ctypes
u = ctypes.windll.user32
try:
scale = u.GetDpiForSystem() / 96.0
except Exception:
scale = 1.0
sw = u.GetSystemMetrics(0) / (scale or 1.0)
sh = u.GetSystemMetrics(1) / (scale or 1.0)
except Exception:
pass
w = int(min(1060, max(880, sw * 0.62)))
h = int(min(760, max(600, sh * 0.75)))
win = webview.create_window("NetDesk", _html_path(), js_api=api,
width=w, height=h, min_size=(860, 590),
x=max(20, int((sw - w) / 2)), y=max(20, int((sh - h) / 2.4)),
maximized=False,
frameless=True, easy_drag=False,
background_color="#0b0f15")
def _on_closing():
# native ✕ hides to tray by default (RustDesk/TeamViewer behavior),
# or really quits when "Close to tray" is off
if api.cfg.data.get("close_to_tray", True):
api.hide_window()
return False # cancel the close - just hide
api.quit_all()
return True
try:
win.events.closing += _on_closing
except Exception:
pass
threading.Thread(target=_start_tray, args=(api,), daemon=True).start()
# Auto bring the host ONLINE on launch (TeamViewer-style), and on the very
# first run default "Start with Windows" ON so it reconnects at every login.
# Both are safe (a fresh config always carries a random password) and fully
# reversible from the toggles. Runs off-thread so the UI never blocks on it.
def _autostart():
try:
api.ensure_engine()
except Exception:
pass
try:
if IS_WIN and not api.cfg.data.get("autostart_initialized"):
api.add_startup()
api.cfg.data["autostart_initialized"] = True
api.cfg.save()
except Exception:
pass
threading.Thread(target=_autostart, daemon=True).start()
webview.start()
def selftest():
import webview
api = Api()
result = {"ok": False}
def _check():
time.sleep(2.2)
try:
st = api.get_state()
result["ok"] = isinstance(st, dict) and "id" in st
except Exception as e:
result["err"] = str(e)
for w in webview.windows:
try:
w.destroy()
except Exception:
pass
threading.Thread(target=_check, daemon=True).start()
webview.create_window("NetDesk selftest", _html_path(), js_api=api,
width=580, height=760, background_color="#0b0e13")
webview.start()
if result["ok"]:
print("GUI_SELFTEST_OK")
else:
print("GUI_SELFTEST_FAIL", result)
sys.exit(1)
if __name__ == "__main__":
run()
+3318
View File
File diff suppressed because it is too large. Load diff
+112
View File
@@ -0,0 +1,112 @@
"""Interactive command terminal for the NetDesk host ("cmd").
Spawns a shell and streams its output, so a connected viewer gets a working
console: run commands and see their output live (dir, ipconfig, ping, python
scripts, git, etc.). Uses a pipe rather than a full pseudo-console — that covers
command execution; full-screen TUI apps (vim, a nested shell UI) would need a
ConPTY and are a future enhancement.
Wire protocol (host side):
viewer -> host : {"t":"term.open","tid":N} open a shell
viewer -> host : {"t":"term.data","tid":N,"d":"<text>"} send keystrokes/commands
viewer -> host : {"t":"term.resize","tid":N,"cols":C,"rows":R}
viewer -> host : {"t":"term.close","tid":N}
host -> viewer: {"t":"term.data","tid":N,"d":"<text>"} shell output (streamed)
host -> viewer: {"t":"term.exit","tid":N} shell ended
Developed by Akronlogic.
"""
import os
import subprocess
import threading
CREATE_NO_WINDOW = 0x08000000
class TerminalSession:
"""One shell process. `on_output(text: str)` is invoked from a reader
thread as the shell produces output; it is called once with "" when the
shell exits."""
def __init__(self, on_output, shell=None, cwd=None):
self._on_output = on_output
shell = shell or os.environ.get("COMSPEC", "cmd.exe")
self._p = subprocess.Popen(
shell,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0,
cwd=cwd,
creationflags=CREATE_NO_WINDOW if os.name == "nt" else 0,
)
self._alive = True
self._reader = threading.Thread(target=self._pump, daemon=True)
self._reader.start()
def _pump(self):
try:
while True:
chunk = self._p.stdout.read(4096)
if not chunk:
break
try:
self._on_output(chunk.decode("utf-8", "replace"))
except Exception:
pass
finally:
self._alive = False
try:
self._on_output("") # EOF -> shell closed
except Exception:
pass
def write(self, data):
"""Send keystrokes / a command line (include the trailing \\r\\n)."""
if isinstance(data, str):
data = data.encode("utf-8", "replace")
try:
self._p.stdin.write(data)
self._p.stdin.flush()
return True
except Exception:
return False
def resize(self, cols, rows):
# Pipe mode has no window size; accepted for API parity (ConPTY later).
return None
@property
def alive(self):
return self._alive and self._p.poll() is None
def close(self):
self._alive = False
try:
self._p.stdin.close()
except Exception:
pass
try:
self._p.terminate()
except Exception:
pass
if __name__ == "__main__":
import time
buf = []
term = TerminalSession(on_output=buf.append)
time.sleep(0.3)
term.write("echo NETDESK_TERM_OK_777\r\n")
time.sleep(0.6)
term.write("cd\r\n")
time.sleep(0.4)
term.write("exit\r\n")
time.sleep(0.4)
term.close()
text = "".join(buf)
ok = "NETDESK_TERM_OK_777" in text
print("SELFTEST", "PASS" if ok else "FAIL", "| chars:", len(text))
if not ok:
print(text[:800])
+10
View File
@@ -0,0 +1,10 @@
# Statically link the MinGW C/C++ runtime so our binaries are SELF-CONTAINED
# (no libstdc++-6.dll / libgcc_s_seh-1.dll / libwinpthread-1.dll dependency).
# This is required for the portable build and a clean installer — a user must be
# able to run NetDesk without installing any MinGW runtime.
[target.x86_64-pc-windows-gnu]
rustflags = [
"-C", "link-arg=-static",
"-C", "link-arg=-static-libgcc",
"-C", "link-arg=-static-libstdc++",
]
+22
View File
@@ -0,0 +1,22 @@
/target
**/*.rs.bk
Cargo.lock.bak
# Flutter (Phase 3)
/ui/build/
/ui/.dart_tool/
/ui/.flutter-plugins*
# local secrets / test creds
*.env
.secrets
akronlogic-codesign.cer
# packaging build outputs
packaging/dist/
# Android generated native libs (built by cargo-ndk)
ui/android/app/src/main/jniLibs/
# Flutter build + local
ui/build/
ui/.dart_tool/
ui/android/key.properties
*.keystore
+5758
View File
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
# NetDesk — native cross-platform remote desktop client.
# A true compiled Rust core (networking, E2E crypto, transport, codec, input)
# with a Flutter UI layer on top (Windows / macOS / Linux / iOS / Android).
# Developed by Akronlogic (https://akronlogic.com). Full IP ownership — no AGPL.
[workspace]
resolver = "2"
members = ["core", "cli", "viewer", "frb", "host", "vpx", "opus"]
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["Akronlogic <https://akronlogic.com>"]
license = "Proprietary"
repository = ""
[workspace.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
hmac = "0.12"
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
p256 = { version = "0.13", features = ["ecdh"] }
aes-gcm = "0.10"
base64 = "0.22"
hex = "0.4"
anyhow = "1"
thiserror = "1"
rand = "0.8"
openh264 = "0.6"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
+63
View File
@@ -0,0 +1,63 @@
# NetDesk — Native Remote Desktop
A true native, cross-platform remote-desktop client. **Developed by [Akronlogic](https://akronlogic.com).**
Full IP ownership — built from scratch, no AGPL/third-party copyleft.
> Goal: a premium, TeamViewer-grade experience (RustDesk is our open-source
> *reference* for features and approach — we re-implement, we don't vendor it).
## Architecture
```
netdesk-native/
├── core/ Rust core library → the compiled engine (one codebase, all platforms)
│ signaling · E2E crypto · media transport · H.264 codec · input
│ exposes a C ABI (cdylib/staticlib) for the UI layer
├── cli/ Headless test harness (how the core is tested in CI / during build-out)
└── ui/ Flutter app (added in Phase 3) → Windows/macOS/Linux/iOS/Android UI
```
Why Rust core + Flutter UI: it is the only stack that gives one native codebase
across all five targets (desktop **and** mobile) with native performance —
the same architecture RustDesk proved. The heavy, latency-critical work lives in
Rust; the UI is Flutter calling in over the C ABI.
## Compatibility
The client speaks the **existing NetDesk server protocol** (the self-hosted
signaling/relay at `netdesk.space`), so it interoperates with the current web
viewer and host engine during the transition:
- `session.begin` → `session.challenge` → `session.auth` → `session.accept`
- Auth: `verifier = PBKDF2-HMAC-SHA256(password, salt, iters)`,
`proof = SHA256(nonce ‖ verifier)`
- E2E: ECDH P-256 → `SHA256(shared.x ‖ "e2e")` → AES-256-GCM
- Media: AES-GCM(`iv[12] ‖ ciphertext`), plaintext `= [chan][keyframe][H.264 Annex-B]`
## Roadmap
- [x] **Phase 1 — Core network + crypto** (this commit): WSS signaling, full
auth + E2E handshake, decrypted H.264 frame stream, headless CLI that
verifies a live session against the real server. *Tested.*
- [ ] **Phase 2 — Decode + render**: H.264 hardware decode (openh264/FFmpeg/
platform decoders), frame pacing, a minimal native window showing the
remote screen; input injection back to the host.
- [ ] **Phase 3 — Flutter UI**: the NetDesk app shell (connect screen, session
toolbar, address book), Akronlogic branding, over the Rust C ABI.
- [ ] **Phase 4 — Transport**: WebRTC P2P (direct) + TURN, matching the server's
3-tier path; adaptive bitrate.
- [ ] **Phase 5 — Feature parity**: multi-monitor, file transfer, clipboard,
audio, unattended access, auto-start service, auto-update.
- [ ] **Phase 6 — Packaging + signing**: signed installers per platform
(Akronlogic code-signing), store builds for iOS/Android.
## Build
```sh
# Rust core + CLI (Windows uses the GNU toolchain + MinGW-w64 binutils)
cargo build --release
# verify against a live host:
target/release/netdesk <host-id> <password> wss://netdesk.space/ws 10
```
© Akronlogic. All rights reserved.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="ndg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#25d386"/><stop offset="1" stop-color="#16a765"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#ndg)"/><path d="M16 6.5L6.5 18.2C6.1 18.7 6.4 19.4 7 19.4H25C25.6 19.4 25.9 18.7 25.5 18.2L16 6.5Z" fill="#fff"/><path d="M11.8 21H20.2L21.5 26.4C21.6 26.8 21.3 27.2 20.9 27.2H11.1C10.7 27.2 10.4 26.8 10.5 26.4L11.8 21Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 510 B

+97
View File
@@ -0,0 +1,97 @@
[package]
name = "netdesk-cli"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "Headless NetDesk native client — connects to a host and verifies the pipeline (dev/test harness)."
[[bin]]
name = "netdesk"
path = "src/main.rs"
# End-to-end feature test harness: connects to the live host and verifies every
# Session feature + the read-only host_ctl backend. See src/bin/e2e.rs.
[[bin]]
name = "netdesk-e2e"
path = "src/bin/e2e.rs"
# TCP tunnel client: forward a local port through the relay to a port on the
# remote host (SSH/VNC/RDP/DB over NetDesk). Portal-authed.
[[bin]]
name = "netdesk-tunnel"
path = "src/bin/tunnel.rs"
# LAN discovery scanner: find NetDesk hosts on the local network.
[[bin]]
name = "netdesk-lan-scan"
path = "src/bin/lan_scan.rs"
# Recursive folder download: pull a whole remote folder tree to a local dir.
[[bin]]
name = "netdesk-getdir"
path = "src/bin/getdir.rs"
# Interactive terminal: run commands on the remote host's shell (ConPTY).
[[bin]]
name = "netdesk-term"
path = "src/bin/term.rs"
# Voice send-test: capture this PC's mic and stream it to the host over
# CHAN_VOICE (drives the exact native-viewer voice path, headlessly).
[[bin]]
name = "netdesk-voice-send"
path = "src/bin/voice_send.rs"
# FPS probe: count received video frames per 1-second bucket (observes the
# host's startup FPS ramp end to end).
[[bin]]
name = "netdesk-fps-probe"
path = "src/bin/fps_probe.rs"
# Privacy-mode integration test: toggle privacy + verify sys.res and that the
# capture keeps delivering frames through the blackout.
[[bin]]
name = "netdesk-privacy-test"
path = "src/bin/privacy_test.rs"
# Direct-IP connect test: dial a host's IP:port directly (no relay) over
# DirectTcp and verify the E2E session + video.
[[bin]]
name = "netdesk-direct"
path = "src/bin/direct_connect.rs"
[dependencies]
netdesk-core = { path = "../core" }
# e2e decodes received Opus audio packets to measure real per-source energy
# (RMS/peak) — proving system + mic audio actually transmit, not just that
# packets arrive.
netdesk-opus = { path = "../opus" }
tokio = { workspace = true }
anyhow = { workspace = true }
# e2e.rs parses sys.res / session.info JSON and pulls in the viewer's host_ctl
# module verbatim (via #[path]); host_ctl needs serde derive, serde_json, a
# blocking HTTP client (ureq) and the clipboard crate it references.
serde = { workspace = true }
serde_json = { workspace = true }
ureq = { version = "2.10", features = ["json"] }
arboard = "3"
# netdesk-tunnel client: portal-login (ureq) then a raw tunnel WebSocket to
# wss://server/tunnel/<hid>, piping a local TcpListener through it.
tokio-tungstenite = { workspace = true }
futures-util = { workspace = true }
# e2e.rs verifies REAL OS-level input injection on the host (which, for the live
# test, runs on this same machine): GetCursorPos confirms the mouse actually
# moved, GetAsyncKeyState confirms a key actually registered. DPI-aware so the
# readings share the host's physical pixel space.
[target.'cfg(windows)'.dependencies]
# the e2e bin #[path]-includes viewer/src/host_ctl.rs, which drives the in-process
# native host engine for the HOSTCTL checks.
netdesk-host = { path = "../host" }
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_HiDpi",
] }
+80
View File
@@ -0,0 +1,80 @@
//! NetDesk direct-IP connect test — dials a host's IP:port directly (no relay),
//! runs the same challenge/auth + E2E over a raw TCP socket (DirectTcp), and
//! verifies video decodes. Proves encrypted direct-IP access. Developed by
//! Akronlogic.
//!
//! Usage: netdesk-direct <ip:port> <host_id> <password> [secs]
use netdesk_core::signaling::connect_with;
use netdesk_core::transport::DirectTcp;
use netdesk_core::{ConnectConfig, SessionEvent};
use std::time::{Duration, Instant};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 4 {
eprintln!("usage: netdesk-direct <ip:port> <host_id> <password> [secs]");
std::process::exit(2);
}
let addr = a[1].clone();
let secs: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
eprintln!("[direct] dialing {addr} …");
let stream = TcpStream::connect(&addr)
.await
.map_err(|e| anyhow::anyhow!("tcp connect {addr}: {e}"))?;
let transport = DirectTcp::new(stream);
let cfg = ConnectConfig {
host_id: a[2].clone(),
password: a[3].clone(),
ws_url: String::new(), // unused: the transport is provided directly
totp: String::new(),
takeover: false,
};
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
let session = connect_with(cfg, tx, Box::new(transport))
.await
.map_err(|e| anyhow::anyhow!("connect_with: {e}"))?;
eprintln!("[direct] connected — E2E handshake completed over raw TCP; waiting for video …");
let mut frames = 0u32;
let mut e2e = false;
let deadline = Instant::now() + Duration::from_secs(secs + 4);
loop {
if Instant::now() >= deadline {
break;
}
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
Ok(Some(SessionEvent::Connected { host_name, e2e: ee, w, h })) => {
e2e = ee;
eprintln!("[direct] session.accept host='{host_name}' {w}x{h} e2e={ee}");
}
Ok(Some(SessionEvent::Video { .. })) => {
frames += 1;
if frames >= 5 {
break;
}
}
Ok(Some(SessionEvent::Denied { reason })) => {
eprintln!("[direct] denied: {reason}");
std::process::exit(3);
}
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => break,
Ok(Some(_)) => {}
Err(_) => {}
}
}
session.close();
if frames > 0 {
println!("DIRECT_E2E_PASS frames={frames} e2e={e2e} addr={addr}");
Ok(())
} else {
println!("DIRECT_E2E_FAIL frames=0 e2e={e2e}");
std::process::exit(4);
}
}
File diff suppressed because it is too large. Load diff
+73
View File
@@ -0,0 +1,73 @@
//! NetDesk FPS probe — connects as a viewer and counts received video frames in
//! 1-second buckets, so the host's startup FPS ramp (qos::startup_fps: begins at
//! INIT_FPS, climbs to target over RAMP_MS) is observable end to end: the first
//! buckets should be ~INIT_FPS and climb to the target. Developed by Akronlogic.
//!
//! Usage: netdesk-fps-probe <host_id> <password> [ws_url] [secs]
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 3 {
eprintln!("usage: netdesk-fps-probe <host_id> <password> [ws_url] [secs]");
std::process::exit(2);
}
let secs: usize = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
let cfg = ConnectConfig {
host_id: a[1].clone(),
password: a[2].clone(),
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
totp: String::new(),
takeover: false,
};
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
eprintln!("[fps-probe] connecting to host {} …", cfg.host_id);
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
// Count Video frames per 1-second bucket, timed from the FIRST frame (so the
// connect/keyframe latency before frame 1 doesn't skew bucket 0).
let mut buckets: Vec<u32> = vec![0; secs + 2];
let mut first: Option<Instant> = None;
let hard_deadline = Instant::now() + Duration::from_secs(secs as u64 + 6);
loop {
if Instant::now() >= hard_deadline {
break;
}
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
Ok(Some(SessionEvent::Video { .. })) => {
let t0 = *first.get_or_insert_with(Instant::now);
let b = t0.elapsed().as_secs() as usize;
if b < buckets.len() {
buckets[b] += 1;
}
if t0.elapsed().as_secs() as usize >= secs {
break;
}
}
Ok(Some(SessionEvent::Denied { reason })) => {
eprintln!("[fps-probe] denied: {reason}");
std::process::exit(3);
}
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => break,
Ok(Some(_)) => {}
Err(_) => {} // 500ms idle tick; keep waiting
}
}
session.close();
if first.is_none() {
println!("FPS_BUCKETS no-frames");
std::process::exit(4);
}
print!("FPS_BUCKETS");
for (i, c) in buckets.iter().take(secs + 1).enumerate() {
print!(" t{i}s={c}");
}
println!();
Ok(())
}
+125
View File
@@ -0,0 +1,125 @@
//! NetDesk recursive folder download — pull an entire remote folder tree to a
//! local directory, preserving structure (RustDesk-style folder transfer). It
//! asks the host for a recursive manifest (`file.walk`) then downloads each file
//! into `<local_dir>/<relative path>`. Developed by Akronlogic.
//!
//! Usage:
//! netdesk-getdir <host_id> <password> <remote_dir> <local_dir> [ws_url] [totp]
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::timeout;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 5 {
eprintln!("usage: netdesk-getdir <host_id> <password> <remote_dir> <local_dir> [ws_url] [totp]");
std::process::exit(2);
}
let cfg = ConnectConfig {
host_id: a[1].clone(),
password: a[2].clone(),
ws_url: a.get(5).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
totp: a.get(6).cloned().unwrap_or_default(),
takeover: false,
};
let remote_dir = a[3].trim_end_matches(['\\', '/']).to_string();
let local_dir = PathBuf::from(&a[4]);
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
eprintln!("[getdir] connecting to host {} …", cfg.host_id);
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
eprintln!("[getdir] connected; requesting manifest of {remote_dir}");
session.walk(&remote_dir);
// Collect the manifest, bounded by an OVERALL deadline (the host streams
// cursor/ping events, so a per-message timeout would never fire).
let manifest = timeout(Duration::from_secs(45), async {
loop {
match rx.recv().await {
Some(SessionEvent::FileWalk { files, truncated, .. }) => return Some((files, truncated)),
Some(SessionEvent::FileError { msg }) => {
eprintln!("[getdir] host error: {msg}");
return None;
}
Some(_) => continue,
None => return None,
}
}
})
.await;
let (files, truncated) = match manifest {
Ok(Some(x)) => x,
_ => return Err(anyhow::anyhow!("no manifest from host (timed out)")),
};
eprintln!(
"[getdir] {} file(s){}; downloading to {}",
files.len(),
if truncated { " (TRUNCATED — hit the host's cap)" } else { "" },
local_dir.display()
);
let mut ok = 0usize;
let mut bytes_total = 0u64;
for (rel, size) in &files {
// Remote path: host is Windows → backslash-join under remote_dir.
let remote_full = format!("{}\\{}", remote_dir, rel.replace('/', "\\"));
let fid = session.download(&remote_full);
// Wait for THIS file's completion, bounded by an overall per-file deadline
// (unrelated streamed events must not keep us alive indefinitely).
let done_path = timeout(Duration::from_secs(90), async {
loop {
match rx.recv().await {
Some(SessionEvent::FileDownloadDone { fid: f, path, .. }) if f == fid => {
return Some(path)
}
Some(SessionEvent::FileError { msg }) => {
eprintln!("[getdir] skip {rel}: {msg}");
return None;
}
Some(_) => continue, // progress / cursor / ping — ignore
None => return None,
}
}
})
.await
.unwrap_or_else(|_| {
eprintln!("[getdir] timeout on {rel}");
None
});
if let Some(tmp) = done_path {
// Move the downloaded temp file into <local_dir>/<rel>.
let target = local_dir.join(PathBuf::from(rel.replace('/', std::path::MAIN_SEPARATOR_STR)));
if let Some(parent) = target.parent() {
let _ = std::fs::create_dir_all(parent);
}
// rename across drives can fail → fall back to copy+remove.
if std::fs::rename(&tmp, &target).is_err() {
if std::fs::copy(&tmp, &target).is_ok() {
let _ = std::fs::remove_file(&tmp);
} else {
eprintln!("[getdir] could not place {rel}");
continue;
}
}
ok += 1;
bytes_total += size;
}
}
session.close();
println!(
"[getdir] done: {}/{} files, {} bytes -> {}",
ok,
files.len(),
bytes_total,
local_dir.display()
);
if ok != files.len() {
std::process::exit(1);
}
Ok(())
}
+33
View File
@@ -0,0 +1,33 @@
//! NetDesk LAN scan — discover NetDesk hosts on the local network by UDP
//! broadcast (the viewer end of the host's discovery responder). Prints each
//! host's id + name so you can connect by id. Developed by Akronlogic.
//!
//! Usage: netdesk-lan-scan [timeout_ms] (default 800ms)
use netdesk_core::lan;
use std::time::Duration;
fn main() {
let ms: u64 = std::env::args()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(800);
eprintln!("[lan] scanning for NetDesk hosts on udp/{} for {ms}ms…", lan::LAN_PORT);
match lan::scan(&lan::broadcast_targets(), Duration::from_millis(ms)) {
Ok(hosts) if hosts.is_empty() => {
println!("no NetDesk hosts found on this LAN");
}
Ok(hosts) => {
println!("found {} host(s):", hosts.len());
for h in hosts {
// addr.port() is 0 until direct-IP connect ships; the id is what
// a viewer uses to connect through the relay today.
println!(" id={} name=\"{}\" ip={}", h.id, h.name, h.addr.ip());
}
}
Err(e) => {
eprintln!("[lan] scan failed: {e}");
std::process::exit(1);
}
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Privacy-mode integration test. Connects to a host, confirms video is
//! streaming, toggles privacy ON (expects sys.res {kind:"privacy", on:true} and
//! that frames keep flowing — i.e. the capture survives the blackout), then
//! toggles it OFF (on:false). Developed by Akronlogic.
//!
//! Usage: netdesk-privacy-test <host_id> <password> [ws_url]
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
type Rx = mpsc::UnboundedReceiver<SessionEvent>;
async fn wait_for_frame(rx: &mut Rx, frames: &mut u32, secs: u64) -> anyhow::Result<()> {
let start = *frames;
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
Ok(Some(SessionEvent::Video { .. })) => {
*frames += 1;
if *frames > start {
return Ok(());
}
}
Ok(Some(SessionEvent::Denied { reason })) => anyhow::bail!("denied: {reason}"),
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => anyhow::bail!("session ended"),
Ok(Some(_)) => {}
Err(_) => {}
}
}
anyhow::bail!("no video frame within {secs}s")
}
async fn wait_privacy(rx: &mut Rx, frames: &mut u32, secs: u64) -> anyhow::Result<bool> {
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await {
Ok(Some(SessionEvent::Video { .. })) => *frames += 1,
Ok(Some(SessionEvent::SysResult { json })) => {
let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
if v.get("kind").and_then(|k| k.as_str()) == Some("privacy") {
return Ok(v.get("on").and_then(|o| o.as_bool()).unwrap_or(false));
}
}
Ok(Some(SessionEvent::Denied { reason })) => anyhow::bail!("denied: {reason}"),
Ok(Some(SessionEvent::Ended { .. })) | Ok(None) => anyhow::bail!("session ended"),
Ok(Some(_)) => {}
Err(_) => {}
}
}
anyhow::bail!("no privacy sys.res within {secs}s")
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 3 {
eprintln!("usage: netdesk-privacy-test <host_id> <password> [ws_url]");
std::process::exit(2);
}
let cfg = ConnectConfig {
host_id: a[1].clone(),
password: a[2].clone(),
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
totp: String::new(),
takeover: false,
};
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
eprintln!("[privacy-test] connecting to host {} …", cfg.host_id);
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let mut frames = 0u32;
wait_for_frame(&mut rx, &mut frames, 8).await?;
eprintln!("[privacy-test] streaming confirmed; toggling privacy ON");
session.privacy();
let on1 = wait_privacy(&mut rx, &mut frames, 8).await?;
let before = frames;
// Give the blackout a few seconds up; capture must keep delivering frames.
let flowed = wait_for_frame(&mut rx, &mut frames, 5).await.is_ok();
eprintln!("[privacy-test] privacy on={on1}; frames during privacy: {} (was {before})", frames);
session.privacy();
let on2 = wait_privacy(&mut rx, &mut frames, 8).await?;
eprintln!("[privacy-test] privacy on={on2} (toggled off)");
session.close();
if on1 && !on2 && flowed {
println!("PRIVACY_TEST_PASS on->true off->false frames_flowed_during_privacy=true total_frames={frames}");
Ok(())
} else {
println!("PRIVACY_TEST_FAIL on1={on1} on2={on2} flowed={flowed}");
std::process::exit(3);
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Tiny viewer that connects to a host and sends ONE tap (mouse down+up) at
//! TAP_X,TAP_Y (in the host's capture pixel space). Used to verify Android-host
//! input injection on BlueStacks: send a tap, then screencap to confirm effect.
//! E2E_HOST_ID=.. E2E_PASSWORD=.. TAP_X=.. TAP_Y=.. tap-probe
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::time::Duration;
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let id = std::env::var("E2E_HOST_ID").expect("E2E_HOST_ID");
let pw = std::env::var("E2E_PASSWORD").expect("E2E_PASSWORD");
let x: i32 = std::env::var("TAP_X").unwrap_or_else(|_| "480".into()).parse().unwrap();
let y: i32 = std::env::var("TAP_Y").unwrap_or_else(|_| "800".into()).parse().unwrap();
let swipe = std::env::var("SWIPE").ok(); // "x2,y2" -> drag instead of tap
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
let cfg = ConnectConfig {
ws_url: "wss://netdesk.space/ws".into(),
host_id: id,
password: pw,
totp: String::new(),
takeover: false,
};
let session = connect(cfg, tx).await.expect("connect");
// drain until Connected
let mut connected = false;
for _ in 0..50 {
if let Ok(Some(ev)) = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
if matches!(ev, SessionEvent::Connected { .. }) {
connected = true;
break;
}
}
}
if !connected {
println!("TAP_PROBE_FAIL not connected");
return;
}
tokio::time::sleep(Duration::from_millis(400)).await;
// press
session.mouse(x, y, 1, 0.0, 0.0);
if let Some(s) = swipe {
// drag: move with button held to (x2,y2), then release
let parts: Vec<i32> = s.split(',').filter_map(|v| v.trim().parse().ok()).collect();
let (x2, y2) = (parts[0], parts[1]);
for i in 1..=8 {
let ix = x + (x2 - x) * i / 8;
let iy = y + (y2 - y) * i / 8;
tokio::time::sleep(Duration::from_millis(25)).await;
session.mouse(ix, iy, 1, 0.0, 0.0);
}
tokio::time::sleep(Duration::from_millis(40)).await;
session.mouse(x2, y2, 0, 0.0, 0.0);
println!("SWIPE_SENT {x},{y} -> {x2},{y2}");
} else {
tokio::time::sleep(Duration::from_millis(80)).await;
session.mouse(x, y, 0, 0.0, 0.0); // release = tap
println!("TAP_SENT {x},{y}");
}
tokio::time::sleep(Duration::from_millis(600)).await;
session.close();
}
+77
View File
@@ -0,0 +1,77 @@
//! NetDesk remote terminal client — run commands on the remote host's shell via
//! the ConPTY terminal channel and stream the output back. Developed by Akronlogic.
//!
//! Usage:
//! netdesk-term <host_id> <password> [ws_url] [totp]
//! Input: set ND_TERM_CMD="<command>" to run one command, or pipe commands on
//! stdin (e.g. `echo dir | netdesk-term <id> <pw>`). Output streams for a few
//! seconds, then it exits. (A full raw-TTY interactive mode is a follow-on.)
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::io::{Read as _, Write as _};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::timeout;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 3 {
eprintln!("usage: netdesk-term <host_id> <password> [ws_url] [totp] (ND_TERM_CMD=cmd, or pipe stdin)");
std::process::exit(2);
}
let cfg = ConnectConfig {
host_id: a[1].clone(),
password: a[2].clone(),
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
totp: a.get(4).cloned().unwrap_or_default(),
takeover: false,
};
// The command batch to run: ND_TERM_CMD, else whatever is piped on stdin.
let input = if let Ok(cmd) = std::env::var("ND_TERM_CMD") {
format!("{cmd}\r\n")
} else {
tokio::task::spawn_blocking(|| {
let mut s = String::new();
let _ = std::io::stdin().read_to_string(&mut s);
// normalize bare \n to \r\n for the shell
s.replace("\r\n", "\n").replace('\n', "\r\n")
})
.await
.unwrap_or_default()
};
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
eprintln!("[term] connecting to host {} …", cfg.host_id);
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
let tid = 1u32;
session.term_open(tid);
eprintln!("[term] shell opened (tid {tid})");
tokio::time::sleep(Duration::from_millis(700)).await; // let the shell print its prompt
if !input.trim().is_empty() {
session.term_data(tid, &input);
}
// Stream output for a bounded window, echoing everything the shell emits.
let _ = timeout(Duration::from_secs(6), async {
loop {
match rx.recv().await {
Some(SessionEvent::TermData { data, .. }) => {
print!("{data}");
let _ = std::io::stdout().flush();
}
Some(SessionEvent::TermExit { .. }) | Some(SessionEvent::Ended { .. }) => break,
Some(_) => continue,
None => break,
}
}
})
.await;
session.term_close(tid);
session.close();
println!();
Ok(())
}
+210
View File
@@ -0,0 +1,210 @@
//! NetDesk TCP tunnel client — forward a local port through the relay to a port
//! on the remote host (SSH/VNC/RDP/DB over NetDesk), the viewer end of the
//! `/tunnel/<hid>` server endpoint the host now implements.
//!
//! Each accepted local connection opens its own `wss://server/tunnel/<hid>`
//! WebSocket (portal-authed via the `ndportal` cookie), sends `{"t":"open",...}`
//! and then pipes raw bytes both ways. The server asks the host to open the TCP
//! connection and relays the stream.
//!
//! Usage:
//! netdesk-tunnel <host_id> <local_port> <remote_port> [remote_host] \
//! [wss://server/] [portal_password]
//! Env fallbacks: ND_SERVER (https/wss base), ND_PORTAL_PASSWORD.
//!
//! Example — reach the remote host's RDP:
//! netdesk-tunnel 123456789 13389 3389
//! mstsc /v:127.0.0.1:13389
//!
//! Developed by Akronlogic.
use anyhow::{anyhow, Context, Result};
use futures_util::{SinkExt, StreamExt};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::{ClientRequestBuilder, Message};
fn env_or(args: &[String], idx: usize, env: &str, default: &str) -> String {
args.get(idx)
.cloned()
.or_else(|| std::env::var(env).ok())
.unwrap_or_else(|| default.to_string())
}
/// Portal-login over HTTP to obtain the `ndportal` session cookie the tunnel
/// endpoint requires. Returns the raw cookie value (`ndportal=…`).
fn portal_cookie(http_base: &str, password: &str) -> Result<String> {
// The server replies 303 with Set-Cookie: ndportal=…; don't follow the
// redirect so we can read the cookie off the response.
let agent = ureq::builder().redirects(0).build();
let resp = agent
.post(&format!("{http_base}/portal/login"))
.send_form(&[("password", password)]);
let resp = match resp {
Ok(r) => r,
Err(ureq::Error::Status(_code, r)) => r, // 303 is an "error" status to ureq
Err(e) => return Err(anyhow!("portal login request failed: {e}")),
};
let set_cookie = resp
.header("set-cookie")
.ok_or_else(|| anyhow!("portal login did not set a cookie (wrong password?)"))?;
// "ndportal=XYZ; HttpOnly; …" -> "ndportal=XYZ"
let cookie = set_cookie
.split(';')
.next()
.unwrap_or("")
.trim()
.to_string();
if !cookie.starts_with("ndportal=") {
return Err(anyhow!("unexpected portal cookie: {cookie}"));
}
Ok(cookie)
}
#[tokio::main]
async fn main() -> Result<()> {
// `--rdp` forwards to the remote host's RDP port (3389) and launches the
// local Windows RDP client at the forwarded port once it's up — RustDesk's
// "New RDP" workflow. It makes remote_port optional (defaults to 3389).
let mut raw: Vec<String> = std::env::args().collect();
let rdp = raw.iter().any(|a| a == "--rdp");
raw.retain(|a| a != "--rdp");
let args = raw;
if args.len() < 3 || (!rdp && args.len() < 4) {
eprintln!(
"usage: netdesk-tunnel <host_id> <local_port> <remote_port> \
[remote_host] [server] [portal_password]\n\
or: netdesk-tunnel --rdp <host_id> <local_port> [remote_host] [server] [portal_password]"
);
std::process::exit(2);
}
let host_id = args[1].clone();
let local_port: u16 = args[2].parse().context("local_port")?;
// With --rdp the remote port is fixed at 3389 and the positional args shift
// left by one (no remote_port argument).
let (remote_port, shift) = if rdp {
(3389u16, 0usize)
} else {
(args[3].parse().context("remote_port")?, 1usize)
};
let remote_host = env_or(&args, 3 + shift, "ND_TUNNEL_RHOST", "127.0.0.1");
let server = env_or(&args, 4 + shift, "ND_SERVER", "wss://netdesk.space/ws");
let password = env_or(&args, 5 + shift, "ND_PORTAL_PASSWORD", "");
// Normalize the server into an https base (for portal login) + a wss base.
let https_base = server
.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches("/ws")
.trim_end_matches('/')
.to_string();
let wss_base = https_base
.replace("https://", "wss://")
.replace("http://", "ws://");
let tunnel_url = format!("{wss_base}/tunnel/{host_id}");
if password.is_empty() {
return Err(anyhow!(
"portal password required (arg 6 or ND_PORTAL_PASSWORD) — tunnels are portal-authed"
));
}
let cookie = Arc::new(portal_cookie(&https_base, &password)?);
eprintln!("[tunnel] portal authed; forwarding 127.0.0.1:{local_port} -> host {host_id} :{remote_port} ({remote_host})");
let listener = TcpListener::bind(("127.0.0.1", local_port))
.await
.with_context(|| format!("bind 127.0.0.1:{local_port}"))?;
eprintln!("[tunnel] listening on 127.0.0.1:{local_port} (Ctrl+C to stop)");
// --rdp: launch the local RDP client at the forwarded port (Windows mstsc).
if rdp {
#[cfg(windows)]
{
let target = format!("/v:127.0.0.1:{local_port}");
match std::process::Command::new("mstsc").arg(&target).spawn() {
Ok(_) => eprintln!("[tunnel] launched mstsc {target}"),
Err(e) => eprintln!("[tunnel] could not launch mstsc ({e}); connect manually to 127.0.0.1:{local_port}"),
}
}
#[cfg(not(windows))]
eprintln!("[tunnel] --rdp: connect your RDP client to 127.0.0.1:{local_port}");
}
loop {
let (sock, peer) = listener.accept().await?;
let cookie = cookie.clone();
let url = tunnel_url.clone();
let rhost = remote_host.clone();
tokio::spawn(async move {
if let Err(e) = handle_conn(sock, &url, &cookie, &rhost, remote_port).await {
eprintln!("[tunnel] {peer}: {e}");
}
});
}
}
/// Pipe one accepted local TCP connection through its own tunnel WebSocket.
async fn handle_conn(
sock: tokio::net::TcpStream,
tunnel_url: &str,
cookie: &str,
rhost: &str,
rport: u16,
) -> Result<()> {
let uri: tokio_tungstenite::tungstenite::http::Uri =
tunnel_url.parse().context("tunnel uri")?;
let req = ClientRequestBuilder::new(uri).with_header("Cookie", cookie.to_string());
let (ws, _resp) = connect_async(req).await.context("tunnel ws connect")?;
let (mut ws_tx, mut ws_rx) = ws.split();
// Ask the host to open the remote TCP connection.
ws_tx
.send(Message::Text(
serde_json::json!({"t":"open","host":rhost,"port":rport}).to_string(),
))
.await?;
let (mut rd, mut wr) = sock.into_split();
// local socket -> ws (binary)
let up = async move {
let mut buf = vec![0u8; 32 * 1024];
loop {
match rd.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if ws_tx.send(Message::Binary(buf[..n].to_vec())).await.is_err() {
break;
}
}
}
}
let _ = ws_tx.close().await;
};
// ws -> local socket; tunnel.ok/err/closed are control text.
let down = async move {
while let Some(msg) = ws_rx.next().await {
match msg {
Ok(Message::Binary(b)) => {
if wr.write_all(&b).await.is_err() {
break;
}
}
Ok(Message::Text(t)) => {
if t.contains("tunnel.err") || t.contains("tunnel.closed") {
break;
}
}
Ok(Message::Close(_)) | Err(_) => break,
_ => {}
}
}
let _ = wr.shutdown().await;
};
tokio::join!(up, down);
Ok(())
}
+81
View File
@@ -0,0 +1,81 @@
//! NetDesk voice send-test — drives the EXACT path the native viewer's voice
//! button uses (`netdesk_host::audio::run_audio` with source = microphone, and
//! `Session::voice` per Opus packet), headlessly, so we can prove viewer->host
//! two-way voice end to end against a scratch host. Developed by Akronlogic.
//!
//! Usage: netdesk-voice-send <host_id> <password> [ws_url] [secs]
//! Watch the HOST log for `voice: rx N pkts from viewer mic, recent peak X`.
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().collect();
if a.len() < 3 {
eprintln!("usage: netdesk-voice-send <host_id> <password> [ws_url] [secs]");
std::process::exit(2);
}
let secs: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(6);
let cfg = ConnectConfig {
host_id: a[1].clone(),
password: a[2].clone(),
ws_url: a.get(3).cloned().unwrap_or_else(|| "wss://netdesk.space/ws".into()),
totp: String::new(),
takeover: false,
};
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
eprintln!("[voice-send] connecting to host {} …", cfg.host_id);
let session = connect(cfg, tx).await.map_err(|e| anyhow::anyhow!("connect: {e}"))?;
eprintln!("[voice-send] connected; capturing microphone and streaming CHAN_VOICE for {secs}s");
// Mirror viewer::toggle_voice exactly: share the Session behind a Mutex and
// feed each captured Opus packet to Session::voice from the capture thread.
let sess = Arc::new(Mutex::new(session));
let stop = Arc::new(AtomicBool::new(false));
let sent = Arc::new(AtomicUsize::new(0));
#[cfg(windows)]
{
let s2 = sess.clone();
let st2 = stop.clone();
let n2 = sent.clone();
std::thread::spawn(move || {
let src = Arc::new(AtomicU8::new(2)); // 2 = microphone (default capture device)
netdesk_host::audio::run_audio(st2, || true, src, move |opus| {
s2.lock().unwrap().voice(&opus);
n2.fetch_add(1, Ordering::Relaxed);
});
});
}
#[cfg(not(windows))]
{
eprintln!("[voice-send] microphone capture is Windows-only");
}
// Drain session events (ignore them) while the capture thread streams.
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
loop {
tokio::select! {
_ = tokio::time::sleep_until(deadline) => break,
ev = rx.recv() => match ev {
Some(SessionEvent::Ended { .. }) | None => break,
Some(_) => {}
}
}
}
stop.store(true, Ordering::Relaxed);
let n = sent.load(Ordering::Relaxed);
sess.lock().unwrap().close();
println!("VOICE_SEND_DONE sent={n} packets");
if n == 0 {
eprintln!("[voice-send] WARNING: 0 packets — no microphone/capture device?");
std::process::exit(3);
}
Ok(())
}
+192
View File
@@ -0,0 +1,192 @@
//! NetDesk native CLI — a headless end-to-end test harness for the Rust core.
//! Connects to a host through the real signaling server, completes the E2E
//! handshake, and streams video, reporting fps / keyframes / bitrate. This is
//! how the core is tested on CI and during the build-out, before the UI exists.
//!
//! Usage:
//! netdesk <host-id> <password> [ws-url] [seconds]
//! Example:
//! netdesk 123456789 'secret' wss://netdesk.space/ws 10
use netdesk_core::decode::best_decoder_for;
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: netdesk <host-id> <password> [ws-url] [seconds]");
std::process::exit(2);
}
let host_id = args[1].clone();
let password = args[2].clone();
let ws_url = args
.get(3)
.cloned()
.unwrap_or_else(|| "wss://netdesk.space/ws".to_string());
let secs: u64 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(10);
println!("NetDesk native core v{} (by {})", netdesk_core::VERSION, netdesk_core::VENDOR);
println!("connecting to host {host_id} via {ws_url} ...");
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
let cfg = ConnectConfig {
ws_url,
host_id,
password,
totp: String::new(),
takeover: false,
};
let session = match connect(cfg, tx).await {
Ok(s) => s,
Err(e) => {
eprintln!("connect failed: {e}");
std::process::exit(1);
}
};
// ask for an immediate keyframe so we start decoding fast
session.request_keyframe();
// Report the monitors the host advertised (this is what drives the viewer's
// monitor-switch UI). With `switch` as the 5th arg, flip to another monitor
// mid-session to prove monitor.switch works end to end.
let mons: Vec<_> = session.monitors().to_vec();
let cur = session.current_monitor();
println!(
" monitors: {} [{}] current={cur}",
mons.len(),
mons.iter()
.map(|m| format!("#{} {}x{}", m.i, m.w, m.h))
.collect::<Vec<_>>()
.join(", ")
);
let do_switch = args.get(5).map(|s| s == "switch").unwrap_or(false);
let mut switched = false;
let start = Instant::now();
let mut frames = 0u64;
let mut keyframes = 0u64;
let mut bytes = 0u64;
let mut connected = false;
let mut last_report = Instant::now();
// Decode the stream to prove the pixels come through. Codec-aware: follows
// the host's per-frame codec tag (H.264 / VP9), swapping on a keyframe.
let mut cur_codec: u8 = 0;
let mut decoder = best_decoder_for(cur_codec);
let mut decoded = 0u64;
let mut audio_pkts = 0u64;
let mut audio_bytes = 0u64;
let mut last_decoded_dim = (0usize, 0usize);
let mut last_nonblack = 0u64; // non-black pixels in the last decoded frame
loop {
let remaining = Duration::from_secs(secs).checked_sub(start.elapsed());
let timeout = match remaining {
Some(r) if !r.is_zero() => r,
_ => break,
};
match tokio::time::timeout(timeout, rx.recv()).await {
Ok(Some(ev)) => match ev {
SessionEvent::Connecting => println!(" . connecting"),
SessionEvent::Challenged { e2e } => println!(" . challenged (e2e={e2e})"),
SessionEvent::Connected { host_name, e2e, w, h } => {
connected = true;
println!(" ✓ CONNECTED to \"{host_name}\" {w}x{h} e2e={e2e}");
}
SessionEvent::Video { keyframe, au, nals, codec } => {
frames += 1;
bytes += au.len() as u64;
if keyframe {
keyframes += 1;
}
// Live monitor-switch test: after a few frames, flip to a
// different monitor and watch the stream follow.
if do_switch && !switched && frames == 30 && mons.len() > 1 {
let other = mons.iter().map(|m| m.i).find(|&i| i != cur).unwrap_or(cur);
println!(" --> monitor.switch to #{other}");
session.monitor_switch(other);
switched = true;
}
if codec != cur_codec && keyframe {
println!(" codec switch -> {}", if codec == 1 { "VP9" } else { "H.264" });
decoder = best_decoder_for(codec);
cur_codec = codec;
}
if codec == cur_codec {
match decoder.decode(&au) {
Ok(Some(frame)) => {
decoded += 1;
last_decoded_dim = (frame.w, frame.h);
last_nonblack =
frame.rgb.chunks_exact(3).filter(|p| p[0] | p[1] | p[2] != 0).count() as u64;
}
Ok(None) => {}
Err(e) => {
if decoded == 0 && frames <= 3 {
eprintln!(" decode error: {e}");
}
}
}
}
if last_report.elapsed() >= Duration::from_secs(1) {
let secs_f = start.elapsed().as_secs_f64().max(0.001);
println!(
" {:>4} rx | {:>4} decoded | {:>3} key | {:>5.1} fps | {:>6.2} Mbps | last {}x{} ({} NAL, {} lit px)",
frames,
decoded,
keyframes,
decoded as f64 / secs_f,
(bytes as f64 * 8.0) / secs_f / 1_000_000.0,
last_decoded_dim.0,
last_decoded_dim.1,
nals,
last_nonblack
);
last_report = Instant::now();
}
}
SessionEvent::Audio { data } => {
audio_pkts += 1;
audio_bytes += data.len() as u64;
}
SessionEvent::Denied { reason } => {
eprintln!(" ✗ DENIED: {reason}");
std::process::exit(1);
}
SessionEvent::Ended { reason } => {
println!(" session ended: {reason}");
break;
}
// New control-protocol events are not exercised by this probe.
_ => {}
},
Ok(None) => break,
Err(_) => break, // overall timeout
}
}
session.close();
let secs_f = start.elapsed().as_secs_f64().max(0.001);
println!("\n==== RESULT ====");
println!("connected : {connected}");
println!("audio : {audio_pkts} Opus pkts ({audio_bytes} B)");
println!("frames rx : {frames} ({:.1} fps)", frames as f64 / secs_f);
println!("decoded : {decoded} frames (last {}x{}, {} lit px)", last_decoded_dim.0, last_decoded_dim.1, last_nonblack);
println!("keyframes : {keyframes}");
println!("video : {:.2} Mbps avg", (bytes as f64 * 8.0) / secs_f / 1_000_000.0);
if connected && decoded > 0 && last_nonblack > 0 {
println!("STATUS : OK — native core decoded live H.264 to real pixels ({}x{})", last_decoded_dim.0, last_decoded_dim.1);
} else if connected && frames > 0 {
println!("STATUS : stream received but not decoded (decoder issue)");
} else if connected {
println!("STATUS : CONNECTED but no frames (host idle? request keyframe?)");
} else {
println!("STATUS : FAILED to establish a session");
std::process::exit(1);
}
Ok(())
}
+82
View File
@@ -0,0 +1,82 @@
[package]
name = "netdesk-core"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "NetDesk native remote-desktop core (signaling, E2E crypto, transport, codec). Developed by Akronlogic."
[lib]
name = "netdesk_core"
crate-type = ["lib", "staticlib"] # frb static-links core (rlib); the vestigial netdesk_core cdylib overflowed MinGW export ordinals once webrtc was linked
[dependencies]
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
rustls = { workspace = true }
futures-util = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
# TOTP (RFC 6238) 2FA on the native host — HMAC-SHA1 over the time counter.
sha1 = "0.10"
pbkdf2 = { workspace = true }
p256 = { workspace = true }
aes-gcm = { workspace = true }
base64 = { workspace = true }
hex = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
rand = { workspace = true }
openh264 = { workspace = true }
tracing = { workspace = true }
# Host-engine permission gates + approve flow log through the platform's logger
# (env_logger in the Windows host, logcat via android_logger on Android).
log = "0.4"
# Session recording: repackage the received H.264 into a playable .mp4 (no re-encode).
mp4 = "0.14"
webrtc = "0.17.2"
async-trait = "0.1"
bytes = "1"
# Windows-only: Media Foundation hardware H.264 decode (DXVA via the system
# H264 decoder MFT). Guarded behind cfg(windows); the core still builds on the
# other four targets with openh264 software decode only. The "windows" crate
# links the GNU import libraries from windows-targets, so it builds on the
# x86_64-pc-windows-gnu toolchain used for the portable build.
[target.'cfg(windows)'.dependencies]
# VP9 software decode (libvpx) for constrained links. Windows-only for now: the
# vendored static libvpx targets the x86_64-pc-windows-gnu ABI.
netdesk-vpx = { path = "../vpx" }
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_Media_MediaFoundation",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
] }
# macOS / iOS: VideoToolbox hardware H.264 decode (decode_vt.rs). The module
# links the system CoreMedia / CoreVideo / VideoToolbox frameworks directly and
# uses core-foundation only to build the pixel-buffer attribute dictionary that
# bridges into those calls. cfg-gated behind the Apple targets, so the Windows,
# Linux and Android builds never reference these crates.
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
core-foundation = "0.10"
core-foundation-sys = "0.8"
# Android: MediaCodec hardware H.264 decode (decode_mediacodec.rs) via the NDK
# Media C ABI in libmediandk — the same ABI the ndk-sys crate binds. cfg-gated
# behind target_os = "android".
[target.'cfg(target_os = "android")'.dependencies]
ndk-sys = "0.6"
# Linux: VAAPI hardware H.264 decode (decode_vaapi.rs) through FFmpeg /
# libavcodec. This pulls libav* in via ffmpeg-next and needs the system FFmpeg
# (>= 4) development libraries present at build time — on Linux only; the crate
# and its build script are never compiled for the other targets.
[target.'cfg(target_os = "linux")'.dependencies]
ffmpeg-next = "9.0"
+214
View File
@@ -0,0 +1,214 @@
//! White-label support — a renamed executable self-configures. A reseller /
//! MSP can ship a pre-pointed client WITHOUT rebuilding, just by renaming the
//! exe so its name encodes the relay + registration key (RustDesk's
//! `rustdesk-host=...,key=....exe` trick). The viewer reads its OWN filename at
//! startup and writes the encoded settings into config.json.
//!
//! Format (filename stem): `<BrandName>-<k=v,k=v,...>`, e.g.
//! `NetDesk-server=relay.corp.example,key=CORP1.exe`
//! `AcmeRemote-server=vpn.acme.io,port=8443,tls=1,name=Acme,id=100200300.exe`
//! Keys: `server` (bare host — filenames can't hold `://` or `:`, so the scheme
//! and `/ws` path are reconstructed), `port`, `tls` (0/1), `key` (registration
//! key), `id`, `name`. The part before the first recognised `key=` is the brand
//! name (empty / "NetDesk" means default). Pure + deterministic for unit tests.
/// Settings decoded from an executable's filename.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FilenameConfig {
/// Product name from the filename prefix (None = default "NetDesk").
pub brand: Option<String>,
/// Full relay URL, reconstructed from the bare host (+ port/tls).
pub server: Option<String>,
pub id: Option<String>,
pub key: Option<String>,
pub name: Option<String>,
}
impl FilenameConfig {
/// True when the filename carried any self-config settings.
pub fn has_config(&self) -> bool {
self.server.is_some() || self.id.is_some() || self.key.is_some() || self.name.is_some()
}
}
/// Parse a filename STEM (no extension) into its encoded settings.
pub fn parse_filename_config(stem: &str) -> FilenameConfig {
const KEYS: &[&str] = &["server=", "id=", "key=", "name=", "port=", "tls="];
// Find where the k=v block starts (the earliest recognised key).
let start = KEYS.iter().filter_map(|k| stem.find(k)).min();
let Some(start) = start else {
return FilenameConfig::default(); // no config encoded (e.g. "NetDesk-Portable")
};
let brand_raw = stem[..start].trim_end_matches('-').trim();
let brand = if brand_raw.is_empty() || brand_raw.eq_ignore_ascii_case("netdesk") {
None
} else {
Some(brand_raw.to_string())
};
let mut out = FilenameConfig {
brand,
..Default::default()
};
let mut host: Option<String> = None;
let mut port: Option<String> = None;
let mut tls = true;
for part in stem[start..].split(',') {
let Some((k, v)) = part.split_once('=') else {
continue;
};
let v = v.trim().to_string();
if v.is_empty() {
continue;
}
match k.trim() {
"server" => host = Some(v),
"port" => port = Some(v),
"tls" => tls = !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("no")),
"id" => out.id = Some(v),
"key" => out.key = Some(v),
"name" => out.name = Some(v),
_ => {}
}
}
// Rebuild a full ws URL from the bare host (filenames can't contain :// or :).
if let Some(h) = host {
let scheme = if tls { "wss" } else { "ws" };
let hostport = match port {
Some(p) => format!("{h}:{p}"),
None => h,
};
out.server = Some(format!("{scheme}://{hostport}/ws"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_name_has_no_config() {
assert_eq!(parse_filename_config("NetDesk"), FilenameConfig::default());
// "NetDesk-Portable" must NOT be mistaken for a config.
assert_eq!(parse_filename_config("NetDesk-Portable"), FilenameConfig::default());
assert!(!parse_filename_config("NetDesk").has_config());
}
#[test]
fn decodes_server_and_key_default_brand() {
let c = parse_filename_config("NetDesk-server=relay.corp.example,key=CORP1");
assert_eq!(c.server.as_deref(), Some("wss://relay.corp.example/ws"));
assert_eq!(c.key.as_deref(), Some("CORP1"));
assert_eq!(c.brand, None);
assert!(c.has_config());
}
#[test]
fn decodes_brand_port_tls_name_id() {
let c = parse_filename_config("AcmeRemote-server=vpn.acme.io,port=8443,tls=1,name=Acme HQ,id=100200300");
assert_eq!(c.brand.as_deref(), Some("AcmeRemote"));
assert_eq!(c.server.as_deref(), Some("wss://vpn.acme.io:8443/ws"));
assert_eq!(c.name.as_deref(), Some("Acme HQ"));
assert_eq!(c.id.as_deref(), Some("100200300"));
}
#[test]
fn tls_zero_gives_ws_scheme() {
let c = parse_filename_config("NetDesk-server=192.168.1.10,tls=0");
assert_eq!(c.server.as_deref(), Some("ws://192.168.1.10/ws"));
}
}
/// A runtime brand manifest (`brand.json` dropped next to the exe) for full
/// white-labeling without a rebuild: product name, vendor, accent colors, a
/// logo PNG (filename, loaded from next to the exe), and a default relay.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct BrandManifest {
pub name: String,
pub vendor: String,
/// Primary accent as `#RRGGBB` (empty = keep the default).
pub accent: String,
pub accent_hi: String,
/// Logo PNG filename, resolved next to the executable.
pub logo: String,
/// Default relay URL (same role as the filename `server=`).
pub server: String,
}
impl BrandManifest {
/// The accent parsed to RGB, if a valid `#RRGGBB` was given.
pub fn accent_rgb(&self) -> Option<(u8, u8, u8)> {
parse_hex_rgb(&self.accent)
}
pub fn accent_hi_rgb(&self) -> Option<(u8, u8, u8)> {
parse_hex_rgb(&self.accent_hi)
}
}
/// Parse `brand.json`. Unknown/missing keys default to empty (a partial manifest
/// is fine — only the fields present override). Invalid JSON -> all defaults.
pub fn parse_brand_manifest(json: &str) -> BrandManifest {
let v: serde_json::Value = serde_json::from_str(json).unwrap_or(serde_json::Value::Null);
let s = |k: &str| {
v.get(k)
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string()
};
BrandManifest {
name: s("name"),
vendor: s("vendor"),
accent: s("accent"),
accent_hi: s("accent_hi"),
logo: s("logo"),
server: s("server"),
}
}
/// Parse `#RRGGBB` (or `RRGGBB`) into (r, g, b).
pub fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
let s = s.trim().trim_start_matches('#');
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some((r, g, b))
}
#[cfg(test)]
mod manifest_tests {
use super::*;
#[test]
fn hex_parses_and_rejects() {
assert_eq!(parse_hex_rgb("#22C55E"), Some((34, 197, 94)));
assert_eq!(parse_hex_rgb("22c55e"), Some((34, 197, 94)));
assert_eq!(parse_hex_rgb("#1E88E5"), Some((30, 136, 229)));
assert_eq!(parse_hex_rgb("#12345"), None); // too short
assert_eq!(parse_hex_rgb("#zzzzzz"), None); // not hex
}
#[test]
fn manifest_parses_fields() {
let json = r##"{"name":"AcmeRemote","vendor":"Acme Corp","accent":"#1E88E5",
"logo":"acme.png","server":"wss://relay.acme.io/ws"}"##;
let m = parse_brand_manifest(json);
assert_eq!(m.name, "AcmeRemote");
assert_eq!(m.vendor, "Acme Corp");
assert_eq!(m.accent_rgb(), Some((30, 136, 229)));
assert_eq!(m.logo, "acme.png");
assert_eq!(m.server, "wss://relay.acme.io/ws");
}
#[test]
fn partial_and_invalid_manifests() {
assert_eq!(parse_brand_manifest(r#"{"name":"OnlyName"}"#).name, "OnlyName");
assert_eq!(parse_brand_manifest("not json").name, "");
assert_eq!(parse_brand_manifest(r#"{"name":"X"}"#).accent_rgb(), None);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! CIDR allow-list for encrypted direct-IP access. An empty list allows any
//! peer (the admin opts into restriction by listing CIDRs); otherwise the peer
//! IP must fall inside one of the comma/space/newline-separated CIDRs. Pure +
//! deterministic, so it unit-tests without a socket. IPv4 and IPv6.
use std::net::IpAddr;
/// Is `ip` allowed by `list`? An empty/whitespace list allows everything (the
/// permissive default); otherwise `ip` must match one entry. A bare address
/// (no `/bits`) matches exactly (host route, /32 or /128).
pub fn allowed(ip: &IpAddr, list: &str) -> bool {
let mut any = false;
for e in list.split(|c| c == ',' || c == ' ' || c == '\n' || c == '\t') {
let e = e.trim();
if e.is_empty() {
continue;
}
any = true;
if cidr_match(ip, e) {
return true;
}
}
!any // no entries -> allow all
}
fn cidr_match(ip: &IpAddr, cidr: &str) -> bool {
let (net_str, bits) = match cidr.split_once('/') {
Some((n, b)) => (n, b.trim().parse::<u32>().ok()),
None => (cidr, None),
};
let net: IpAddr = match net_str.trim().parse() {
Ok(a) => a,
Err(_) => return false,
};
match (ip, net) {
(IpAddr::V4(ip), IpAddr::V4(net)) => {
let bits = bits.unwrap_or(32).min(32);
let mask: u32 = if bits == 0 { 0 } else { u32::MAX << (32 - bits) };
(u32::from(*ip) & mask) == (u32::from(net) & mask)
}
(IpAddr::V6(ip), IpAddr::V6(net)) => {
let bits = bits.unwrap_or(128).min(128);
let mask: u128 = if bits == 0 { 0 } else { u128::MAX << (128 - bits) };
(u128::from(*ip) & mask) == (u128::from(net) & mask)
}
_ => false, // v4 vs v6 never match
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::IpAddr;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn empty_list_allows_all() {
assert!(allowed(&ip("8.8.8.8"), ""));
assert!(allowed(&ip("10.0.0.1"), " "));
}
#[test]
fn matches_inside_cidr_blocks() {
assert!(allowed(&ip("10.1.2.3"), "10.0.0.0/8"));
assert!(!allowed(&ip("11.1.2.3"), "10.0.0.0/8"));
assert!(allowed(&ip("192.168.1.50"), "192.168.1.0/24"));
assert!(!allowed(&ip("192.168.2.50"), "192.168.1.0/24"));
}
#[test]
fn multiple_entries_and_exact_host() {
let list = "127.0.0.1, 10.0.0.0/8 192.168.0.0/16";
assert!(allowed(&ip("127.0.0.1"), list)); // exact host
assert!(allowed(&ip("10.9.9.9"), list));
assert!(allowed(&ip("192.168.5.5"), list));
assert!(!allowed(&ip("172.16.0.1"), list));
}
#[test]
fn v4_and_v6_do_not_cross_match() {
assert!(!allowed(&ip("10.0.0.1"), "::1/128"));
assert!(allowed(&ip("::1"), "::1/128"));
assert!(allowed(&ip("fe80::1"), "fe80::/16"));
}
}
+450
View File
@@ -0,0 +1,450 @@
//! End-to-end crypto matching the web viewer exactly, so the native client
//! authenticates to and decrypts from the same hosts:
//! verifier = hex( PBKDF2-HMAC-SHA256(password, salt, iters, 32) )
//! proof = hex( SHA256( nonce_str || verifier ) )
//! E2E key = SHA256( ECDH_P256(host_pub, our_priv).x || "e2e" ) -> AES-256-GCM
//! frame = AES-GCM(iv = bytes[0..12], ct = bytes[12..])
use crate::error::{NdError, Result};
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use p256::ecdh::EphemeralSecret;
use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint};
use p256::{EncodedPoint, PublicKey};
use rand::RngCore;
use sha2::{Digest, Sha256};
/// Password proof the host verifies. `salt_b64` and `nonce` come from the challenge.
pub fn password_proof(password: &str, salt_b64: &str, nonce: &str, iters: u32) -> Result<String> {
let salt = B64
.decode(salt_b64)
.map_err(|e| NdError::Crypto(format!("salt b64: {e}")))?;
let mut dk = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), &salt, iters, &mut dk);
let verifier = hex::encode(dk);
let mut h = Sha256::new();
h.update(nonce.as_bytes());
h.update(verifier.as_bytes());
Ok(hex::encode(h.finalize()))
}
/// Channel-bound password proof (MITM hardening). Binds the host's ephemeral
/// ECDH public key into the proof: `hex(SHA256(nonce || verifier || host_pub))`.
/// A malicious relay that swaps the host's `pub` in the challenge can't produce
/// a matching proof (it lacks the verifier), and the host verifies against ITS
/// OWN pub — so a key swap is detected and auth fails. `host_pub_b64` is the
/// exact base64 string the host put in `session.challenge` `pub`.
pub fn password_proof_bound(
password: &str,
salt_b64: &str,
nonce: &str,
iters: u32,
host_pub_b64: &str,
) -> Result<String> {
let salt = B64
.decode(salt_b64)
.map_err(|e| NdError::Crypto(format!("salt b64: {e}")))?;
let mut dk = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), &salt, iters, &mut dk);
let verifier = hex::encode(dk);
Ok(bound_proof_from_verifier(nonce, &verifier, host_pub_b64))
}
/// Shared core of the bound proof, from an already-computed `verifier_hex`.
/// Used by the viewer (via [`password_proof_bound`]) and the host verify side
/// (which stores `verifier_hex`), so both compute the digest identically.
pub fn bound_proof_from_verifier(nonce: &str, verifier_hex: &str, host_pub_b64: &str) -> String {
let mut h = Sha256::new();
h.update(nonce.as_bytes());
h.update(verifier_hex.as_bytes());
h.update(host_pub_b64.as_bytes());
hex::encode(h.finalize())
}
/// HOST side: verify a viewer's channel-bound proof against the host's OWN
/// ephemeral pubkey. `host_pub_b64` is what the host sent in the challenge.
/// A relay that swapped the pub can't satisfy this.
pub fn verify_proof_bound(
nonce: &str,
verifier_hex: &str,
host_pub_b64: &str,
proof: &str,
) -> bool {
let want = bound_proof_from_verifier(nonce, verifier_hex, host_pub_b64);
ct_eq(want.as_bytes(), proof.to_lowercase().as_bytes())
}
/// Our ephemeral ECDH keypair + the derived AES-256-GCM session key.
pub struct E2e {
pub our_pub_b64: String,
pub cipher: Aes256Gcm,
}
/// Derive the end-to-end key from the host's public key (base64 raw, 65-byte point).
pub fn derive_e2e(host_pub_b64: &str) -> Result<E2e> {
let host_raw = B64
.decode(host_pub_b64)
.map_err(|e| NdError::Crypto(format!("host pub b64: {e}")))?;
let host_pt = EncodedPoint::from_bytes(&host_raw)
.map_err(|e| NdError::Crypto(format!("host pub point: {e}")))?;
let host_pub = Option::from(PublicKey::from_encoded_point(&host_pt))
.ok_or_else(|| NdError::Crypto("invalid host public key".into()))?;
let secret = EphemeralSecret::random(&mut rand::rngs::OsRng);
let our_pub = secret.public_key().to_encoded_point(false); // uncompressed 0x04||X||Y
let our_pub_b64 = B64.encode(our_pub.as_bytes());
let shared = secret.diffie_hellman(&host_pub);
let mut h = Sha256::new();
h.update(shared.raw_secret_bytes()); // the x-coordinate (32 bytes), as WebCrypto deriveBits
h.update(b"e2e");
let key_bytes = h.finalize();
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
Ok(E2e {
our_pub_b64,
cipher,
})
}
/// Decrypt one binary frame (iv is the first 12 bytes; the rest is ciphertext+tag).
pub fn decrypt_frame(cipher: &Aes256Gcm, data: &[u8]) -> Result<Vec<u8>> {
if data.len() < 12 + 16 {
return Err(NdError::Crypto("frame too short".into()));
}
let nonce = Nonce::from_slice(&data[..12]);
cipher
.decrypt(nonce, &data[12..])
.map_err(|_| NdError::Crypto("GCM decrypt failed".into()))
}
/// Encrypt one binary frame for the wire: `iv(12) || AES-GCM(plaintext)`.
/// The HOST side of [`decrypt_frame`] — the Android host uses this to seal its
/// H.264 access units exactly the way the Python engine does (12-byte random
/// nonce prefix), so the same web/desktop/native viewers decrypt them.
pub fn encrypt_frame(cipher: &Aes256Gcm, plaintext: &[u8]) -> Result<Vec<u8>> {
let mut iv = [0u8; 12];
rand::rngs::OsRng.fill_bytes(&mut iv);
let nonce = Nonce::from_slice(&iv);
let ct = cipher
.encrypt(nonce, plaintext)
.map_err(|_| NdError::Crypto("GCM encrypt failed".into()))?;
let mut out = Vec::with_capacity(12 + ct.len());
out.extend_from_slice(&iv);
out.extend_from_slice(&ct);
Ok(out)
}
/// Constant-time byte compare (mirrors Python's `secrets.compare_digest`).
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut d = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
d |= x ^ y;
}
d == 0
}
/// HOST side of the auth handshake: verify a viewer's password proof.
/// `proof` is what the viewer sent; it must equal `hex(SHA256(nonce || verifier))`
/// where `verifier` is the host's stored `verifier_hex`. Matches the Python
/// host's `Config.check_proof`.
pub fn verify_proof(nonce: &str, verifier_hex: &str, proof: &str) -> bool {
let mut h = Sha256::new();
h.update(nonce.as_bytes());
h.update(verifier_hex.as_bytes());
let want = hex::encode(h.finalize());
ct_eq(want.as_bytes(), proof.to_lowercase().as_bytes())
}
/// HOST side of the E2E ECDH. Created when the host sends `session.challenge`
/// (exposing [`our_pub_b64`] in the challenge), then consumed at `session.auth`
/// via [`derive`] with the viewer's public key to yield the shared AES-256-GCM
/// cipher. Mirror of [`derive_e2e`] (the viewer side): both sides compute
/// `SHA256( ECDH_P256(x) || "e2e" )`, so they end up with the SAME key.
pub struct HostE2e {
pub our_pub_b64: String,
secret: EphemeralSecret,
}
impl HostE2e {
/// Generate a fresh ephemeral keypair for one challenge.
pub fn new() -> Self {
let secret = EphemeralSecret::random(&mut rand::rngs::OsRng);
let our_pub = secret.public_key().to_encoded_point(false); // 0x04||X||Y
let our_pub_b64 = B64.encode(our_pub.as_bytes());
HostE2e {
our_pub_b64,
secret,
}
}
/// Derive the AES-256-GCM session cipher from the viewer's public key
/// (base64 raw 65-byte point). Consumes the keypair (used once).
pub fn derive(self, viewer_pub_b64: &str) -> Result<Aes256Gcm> {
let raw = B64
.decode(viewer_pub_b64)
.map_err(|e| NdError::Crypto(format!("viewer pub b64: {e}")))?;
let pt = EncodedPoint::from_bytes(&raw)
.map_err(|e| NdError::Crypto(format!("viewer pub point: {e}")))?;
let viewer_pub = Option::from(PublicKey::from_encoded_point(&pt))
.ok_or_else(|| NdError::Crypto("invalid viewer public key".into()))?;
let shared = self.secret.diffie_hellman(&viewer_pub);
let mut h = Sha256::new();
h.update(shared.raw_secret_bytes());
h.update(b"e2e");
let key_bytes = h.finalize();
Ok(Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes)))
}
}
impl Default for HostE2e {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_viewer_e2e_agree_and_frames_roundtrip() {
// Host sends its pub in the challenge; viewer derives against it.
let host = HostE2e::new();
let host_pub = host.our_pub_b64.clone();
let viewer = derive_e2e(&host_pub).expect("viewer derive");
// Host derives against the viewer's pub -> both keys must match.
let host_cipher = host.derive(&viewer.our_pub_b64).expect("host derive");
// Host-sealed frame decrypts on the viewer (the real media direction).
let au = b"\x00\x00\x00\x01fake annex-b access unit";
let sealed = encrypt_frame(&host_cipher, au).expect("seal");
let opened = decrypt_frame(&viewer.cipher, &sealed).expect("open");
assert_eq!(&opened, au);
// And the reverse (viewer-sealed -> host opens), proving identical keys.
let sealed2 = encrypt_frame(&viewer.cipher, au).expect("seal2");
let opened2 = decrypt_frame(&host_cipher, &sealed2).expect("open2");
assert_eq!(&opened2, au);
}
#[test]
fn totp_matches_python_and_rfc6238() {
// RFC 6238 SHA-1 test secret ("12345678901234567890" base32). These are
// the exact codes the shipping Python host's totp_now() produced — so
// Rust ≡ Python ≡ any authenticator app.
let s = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
assert_eq!(totp_at(s, 59, 30, 6).unwrap(), "287082");
assert_eq!(totp_at(s, 1_111_111_109, 30, 6).unwrap(), "081804");
assert_eq!(totp_at(s, 1_234_567_890, 30, 6).unwrap(), "005924");
}
#[test]
fn totp_verify_window_and_reject() {
let s = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
// A non-numeric / empty code is always rejected.
assert!(!totp_verify(s, "", 1));
assert!(!totp_verify(s, "abcdef", 1));
// The helper that drives verify accepts the matching code for a step and
// rejects a wrong one (tested time-independently via totp_at parity).
let now_code = totp_at(s, 1_000_000_020, 30, 6).unwrap();
let wrong = if now_code == "000000" { "111111" } else { "000000" };
assert_ne!(now_code, wrong);
}
#[test]
fn bound_proof_known_answer_cross_language() {
// Shared known-answer vector: Rust, the web viewer (JS sha256Hex), and
// the Python host all compute hex(SHA256(nonce||verifier||host_pub)) over
// the same UTF-8 string, so this one hex proves all three agree.
// (Python produced the identical digest for these inputs.)
let got = bound_proof_from_verifier("abc123", "deadbeef", "AAAA_host_pub_b64");
assert_eq!(
got,
"ecc84429d061a1580268f829aa3da0d046f1d6aa8a1c6e86ad46c8deff5d835f"
);
}
#[test]
fn bound_proof_detects_pub_swap() {
let nonce = "0123456789abcdef0123456789abcdef";
let password = "correct horse battery staple";
// Salt + verifier MUST come from the same credentials (new_host_credentials
// picks a random salt), so the viewer's bound proof and the host's verify
// use the same verifier — exactly as a real host stores them.
let (salt_b64, iters, verifier) = new_host_credentials(password);
// The host's real ephemeral pub vs. a relay-swapped one.
let real = HostE2e::new();
let swapped = HostE2e::new();
// A viewer binding the REAL pub verifies; one binding the swapped pub does not.
let good = password_proof_bound(password, &salt_b64, nonce, iters, &real.our_pub_b64)
.expect("bound proof");
assert!(verify_proof_bound(nonce, &verifier, &real.our_pub_b64, &good));
let swapped_proof =
password_proof_bound(password, &salt_b64, nonce, iters, &swapped.our_pub_b64)
.expect("bound proof");
assert!(
!verify_proof_bound(nonce, &verifier, &real.our_pub_b64, &swapped_proof),
"a proof bound to a swapped pub must NOT verify against the real pub"
);
}
#[test]
fn proof_verifies_against_viewer_proof() {
let salt = gen_salt();
let salt_b64 = B64.encode(salt);
let iters = 200_000u32;
let nonce = "0123456789abcdef0123456789abcdef";
let verifier = verifier_hex("hunter2", &salt, iters);
// Viewer computes the proof the way password_proof does.
let proof = password_proof("hunter2", &salt_b64, nonce, iters).expect("proof");
assert!(verify_proof(nonce, &verifier, &proof), "valid proof accepted");
assert!(
!verify_proof(nonce, &verifier, &"00".repeat(32)),
"wrong proof rejected"
);
// Wrong password must not verify.
let bad = password_proof("wrong", &salt_b64, nonce, iters).expect("bad proof");
assert!(!verify_proof(nonce, &verifier, &bad), "wrong password rejected");
}
}
// ---------------------------------------------------------------------------
// Host unattended-password credentials. These mirror the Python host engine's
// `make_verifier` / `gen_password` (netdesk_host.py) EXACTLY so a verifier the
// native panel writes to config.json is accepted by the same engine:
// verifier = hex( PBKDF2-HMAC-SHA256(password, salt, iters=200000, dklen=32) )
// In config.json `salt` and `verifier` are stored hex-encoded, `iters` = 200000.
// (The viewer's host-control module reuses these instead of pulling pbkdf2/rand
// into the viewer crate.)
// ---------------------------------------------------------------------------
/// PBKDF2-HMAC-SHA256 verifier (hex) over RAW salt bytes. Matches the host
/// engine's `make_verifier` and the `verifier` field in config.json.
pub fn verifier_hex(password: &str, salt: &[u8], iters: u32) -> String {
let mut dk = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iters, &mut dk);
hex::encode(dk)
}
/// A fresh random 16-byte salt (matches the host `make_verifier` salt length).
pub fn gen_salt() -> [u8; 16] {
let mut s = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut s);
s
}
/// Fresh credentials for `password`: returns `(salt_hex, iters, verifier_hex)`
/// ready to drop into config.json's `salt` / `iters` / `verifier` fields.
pub fn new_password_record(password: &str) -> (String, u32, String) {
let salt = gen_salt();
let iters = 200_000u32;
let verifier = verifier_hex(password, &salt, iters);
(hex::encode(salt), iters, verifier)
}
/// Fresh host credentials ready to drop into [`crate::host::HostConfig`]:
/// `(salt_b64, iters, verifier_hex)` where `salt_b64` is base64 of the RAW salt
/// (what `session.challenge` carries) and `verifier_hex` checks viewer proofs.
pub fn new_host_credentials(password: &str) -> (String, u32, String) {
let salt = gen_salt();
let iters = 200_000u32;
let verifier = verifier_hex(password, &salt, iters);
(B64.encode(salt), iters, verifier)
}
/// One-click unattended password, no look-alike characters (l/1, o/0) —
/// identical alphabet to the host engine's `gen_password`. The alphabet is 32
/// chars, so `u32 % 32` is perfectly uniform (2^32 is divisible by 32).
pub fn gen_password(n: usize) -> String {
const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyz23456789";
let mut rng = rand::rngs::OsRng;
(0..n)
.map(|_| ALPHABET[(rng.next_u32() as usize) % ALPHABET.len()] as char)
.collect()
}
/// A fresh base32 TOTP secret (32 chars, A–Z + 2–7) — matches the host engine's
/// 2FA secret generation (`string.ascii_uppercase + "234567"`).
pub fn gen_totp_secret() -> String {
const B32: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let mut rng = rand::rngs::OsRng;
(0..32)
.map(|_| B32[(rng.next_u32() as usize) % B32.len()] as char)
.collect()
}
/// Decode an RFC 4648 base32 secret (A–Z, 2–7; padding + case tolerated), as the
/// TOTP secret is stored. Returns None on an invalid character.
fn b32_decode(s: &str) -> Option<Vec<u8>> {
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let mut bits: u32 = 0;
let mut nbits: u32 = 0;
let mut out = Vec::new();
for c in s.trim().trim_end_matches('=').bytes() {
let c = c.to_ascii_uppercase();
let v = A.iter().position(|&x| x == c)? as u32;
bits = (bits << 5) | v;
nbits += 5;
if nbits >= 8 {
nbits -= 8;
out.push((bits >> nbits) as u8);
}
}
Some(out)
}
/// RFC 6238 TOTP code for a specific unix time (SHA-1, `step`s, `digits`).
/// Exposed for tests; `totp_verify` is the runtime entry. Matches the Python
/// host's `totp_now` exactly so the same authenticator app works everywhere.
pub fn totp_at(secret: &str, unix_secs: u64, step: u64, digits: u32) -> Option<String> {
use hmac::{Hmac, Mac};
use sha1::Sha1;
let key = b32_decode(secret)?;
let counter = (unix_secs / step).to_be_bytes();
let mut mac = <Hmac<Sha1> as Mac>::new_from_slice(&key).ok()?;
mac.update(&counter);
let digest = mac.finalize().into_bytes();
let off = (digest[19] & 0x0f) as usize;
let code = ((u32::from(digest[off]) & 0x7f) << 24
| u32::from(digest[off + 1]) << 16
| u32::from(digest[off + 2]) << 8
| u32::from(digest[off + 3]))
% 10u32.pow(digits);
Some(format!("{:0width$}", code, width = digits as usize))
}
/// Verify a 6-digit TOTP code against `secret`, allowing ±`window` 30s steps for
/// clock skew (window=1 ⇒ ±30s). Constant-time compare. Mirrors the Python
/// host's `totp_verify`.
pub fn totp_verify(secret: &str, code: &str, window: i64) -> bool {
use std::time::{SystemTime, UNIX_EPOCH};
let code = code.trim();
if code.is_empty() || !code.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64;
for i in -window..=window {
let t = (now + i * 30).max(0) as u64;
if let Some(c) = totp_at(secret, t, 30, 6) {
if ct_eq(c.as_bytes(), code.as_bytes()) {
return true;
}
}
}
false
}
/// A fresh 9-digit NetDesk host id, matching the engine's
/// `str(secrets.randbelow(900000000) + 100000000)`.
pub fn gen_host_id() -> String {
let n = (rand::rngs::OsRng.next_u64() % 900_000_000) + 100_000_000;
n.to_string()
}
+254
View File
@@ -0,0 +1,254 @@
//! H.264 decode. Phase 2 uses openh264 software decode (portable across all five
//! targets) to turn the Annex-B access units from the host into RGB frames the UI
//! can paint. Hardware decoders (Media Foundation / VideoToolbox / MediaCodec) are
//! layered in later behind this same `Frame` output.
use crate::error::{NdError, Result};
use openh264::decoder::Decoder;
use openh264::formats::YUVSource;
/// One decoded frame, packed RGB8 (w*h*3 bytes), ready to upload to a texture.
pub struct Frame {
pub w: usize,
pub h: usize,
pub rgb: Vec<u8>,
}
/// Object-safe decode interface shared by every backend.
///
/// Both the portable openh264 software decoder ([`H264Decoder`]) and the Windows
/// Media Foundation hardware decoder implement this, so the viewer can own a
/// `Box<dyn VideoDecoder + Send>` and not care which one is running. The method
/// contract is identical to [`H264Decoder::decode`]: feed one Annex-B access
/// unit, get back a frame when the decoder emits a picture.
pub trait VideoDecoder {
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>>;
}
pub struct H264Decoder {
inner: Decoder,
}
impl H264Decoder {
pub fn new() -> Result<Self> {
let inner =
Decoder::new().map_err(|e| NdError::Other(anyhow::anyhow!("openh264 init: {e}")))?;
Ok(Self { inner })
}
/// Feed one Annex-B access unit (keyframes carry SPS+PPS+IDR; deltas carry a
/// slice). Returns the decoded RGB frame when the decoder emits a picture.
pub fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
match self.inner.decode(au) {
Ok(Some(yuv)) => {
let (w, h) = yuv.dimensions();
let mut rgb = vec![0u8; w * h * 3];
yuv.write_rgb8(&mut rgb);
Ok(Some(Frame { w, h, rgb }))
}
Ok(None) => Ok(None),
Err(e) => Err(NdError::Other(anyhow::anyhow!("decode: {e}"))),
}
}
}
impl VideoDecoder for H264Decoder {
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
// Reuse the existing inherent method so both call sites stay in lockstep.
H264Decoder::decode(self, au)
}
}
// Per-platform hardware decoders. Each lives in a sibling file and is compiled
// only on its own OS; everywhere else it is cfg'd out and the openh264 software
// decoder below is used. All implement the same `VideoDecoder` trait, so
// `best_decoder` can hand back a `Box<dyn VideoDecoder + Send>` without the
// viewer knowing or caring which backend is running.
// Windows: DXVA H.264 via Media Foundation.
#[cfg(windows)]
#[path = "decode_mf.rs"]
mod mf;
// macOS / iOS: VideoToolbox (VTDecompressionSession).
#[cfg(any(target_os = "macos", target_os = "ios"))]
#[path = "decode_vt.rs"]
mod vt;
// Android: MediaCodec (NDK AMediaCodec).
#[cfg(target_os = "android")]
#[path = "decode_mediacodec.rs"]
mod mediacodec;
// Linux: VAAPI via FFmpeg/libavcodec.
#[cfg(target_os = "linux")]
#[path = "decode_vaapi.rs"]
mod vaapi;
/// Last-resort decoder that drops every access unit. Only returned by
/// [`best_decoder`] in the (practically impossible) case that even openh264
/// fails to initialize, so callers never have to handle a `None` decoder.
struct NullDecoder;
impl VideoDecoder for NullDecoder {
fn decode(&mut self, _au: &[u8]) -> Result<Option<Frame>> {
Ok(None)
}
}
/// Pick the fastest decoder available on this platform.
///
/// Each OS gets its native hardware H.264 decoder, which is dramatically
/// lower-CPU (and lower-power on laptops/phones) than the portable openh264
/// software decoder:
///
/// * **Windows** — Media Foundation: DXVA2 GPU decode when the adapter supports
/// it, otherwise Microsoft's optimized software MFT.
/// * **macOS / iOS** — VideoToolbox (`VTDecompressionSession`) on the platform
/// video engine.
/// * **Android** — `MediaCodec` (`AMediaCodec`), i.e. the SoC video decoder.
/// * **Linux** — VAAPI through FFmpeg/libavcodec (Intel/AMD GPUs via `/dev/dri`).
///
/// If the platform backend cannot initialize at runtime — no GPU support, a
/// missing runtime, a headless box — this transparently falls back to the
/// openh264 software decoder, which is always available on every target.
///
/// The returned decoder is `Send`, so the viewer's background network thread can
/// own it directly.
pub fn best_decoder() -> Box<dyn VideoDecoder + Send> {
#[cfg(windows)]
{
match mf::MediaFoundationDecoder::new() {
Ok(d) => {
tracing::info!(
"decode: Media Foundation H.264 ({})",
if d.is_hardware() {
"DXVA hardware"
} else {
"MS software MFT"
}
);
return Box::new(d);
}
Err(e) => {
tracing::warn!(
"decode: Media Foundation init failed ({e}); using openh264 software decode"
);
}
}
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
{
match vt::VideoToolboxDecoder::new() {
Ok(d) => {
tracing::info!("decode: VideoToolbox H.264 (hardware)");
return Box::new(d);
}
Err(e) => {
tracing::warn!(
"decode: VideoToolbox init failed ({e}); using openh264 software decode"
);
}
}
}
#[cfg(target_os = "android")]
{
match mediacodec::MediaCodecDecoder::new() {
Ok(d) => {
tracing::info!("decode: MediaCodec H.264 (hardware)");
return Box::new(d);
}
Err(e) => {
tracing::warn!(
"decode: MediaCodec init failed ({e}); using openh264 software decode"
);
}
}
}
#[cfg(target_os = "linux")]
{
match vaapi::VaapiDecoder::new() {
Ok(d) => {
tracing::info!("decode: VAAPI H.264 (FFmpeg hardware)");
return Box::new(d);
}
Err(e) => {
tracing::warn!("decode: VAAPI init failed ({e}); using openh264 software decode");
}
}
}
match H264Decoder::new() {
Ok(d) => Box::new(d),
Err(e) => {
tracing::error!("decode: openh264 init failed ({e}); video will not render");
Box::new(NullDecoder)
}
}
}
/// VP9 software decoder (libvpx via the netdesk-vpx crate). Windows-only for now
/// — the vendored static libvpx is built for the x86_64-pc-windows-gnu ABI; the
/// other platforms keep hardware H.264 until their libvpx is vendored too.
#[cfg(windows)]
pub struct Vp9Decoder {
inner: netdesk_vpx::Decoder,
}
#[cfg(windows)]
impl Vp9Decoder {
pub fn new() -> Result<Self> {
let inner = netdesk_vpx::Decoder::new(netdesk_vpx::VpxCodec::Vp9)
.map_err(|e| NdError::Other(anyhow::anyhow!("vp9 decoder init: {e}")))?;
Ok(Self { inner })
}
}
#[cfg(windows)]
impl VideoDecoder for Vp9Decoder {
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
let frames = self
.inner
.decode_to_bgra(au)
.map_err(|e| NdError::Other(anyhow::anyhow!("vp9 decode: {e}")))?;
// One compressed packet yields 0 or 1 pictures; take it.
let Some(f) = frames.into_iter().next() else {
return Ok(None);
};
let (w, h) = (f.width as usize, f.height as usize);
let mut rgb = vec![0u8; w * h * 3];
for i in 0..(w * h) {
// BGRA -> RGB (drop alpha, swap B/R)
rgb[i * 3] = f.bgra[i * 4 + 2];
rgb[i * 3 + 1] = f.bgra[i * 4 + 1];
rgb[i * 3 + 2] = f.bgra[i * 4];
}
Ok(Some(Frame { w, h, rgb }))
}
}
/// Pick a decoder for the given codec id (`proto::VideoCodec`): VP9 (1) uses the
/// libvpx software decoder; anything else uses the platform H.264 decoder from
/// [`best_decoder`]. The viewer calls this whenever a frame's codec tag changes
/// (always at a keyframe), so it can follow the host switching codecs live.
pub fn best_decoder_for(codec: u8) -> Box<dyn VideoDecoder + Send> {
#[cfg(windows)]
{
if codec == crate::proto::VideoCodec::Vp9 as u8 {
match Vp9Decoder::new() {
Ok(d) => {
tracing::info!("decode: VP9 (libvpx software)");
return Box::new(d);
}
Err(e) => {
tracing::warn!("decode: VP9 init failed ({e}); falling back to H.264");
}
}
}
}
let _ = codec;
best_decoder()
}
+512
View File
@@ -0,0 +1,512 @@
//! Android hardware H.264 decode via `MediaCodec` (the NDK `AMediaCodec` API).
//!
//! This drives a byte-buffer-mode `AMediaCodec` H.264 decoder: the host's
//! Annex-B access units go in as input buffers, and the decoder hands back
//! YUV 4:2:0 output buffers (NV12 semi-planar or I420 planar depending on the
//! device) which we convert to the same packed-RGB8 [`Frame`] the openh264 path
//! produces, so the viewer is unchanged. On essentially all Android devices this
//! binds to the SoC's hardware video decoder (OMX / Codec2).
//!
//! The module is only compiled on Android (see the `#[cfg(target_os = "android")]`
//! gate in `decode.rs`). It talks to the stable NDK Media C ABI in `libmediandk`
//! directly — the same ABI the `ndk`/`ndk-sys` crates wrap — so the FFI surface
//! is self-contained and version-independent. If init fails at runtime,
//! [`super::best_decoder`] falls back to the portable openh264 decoder.
//!
//! ## Flow
//! * The codec must be configured with the stream's SPS/PPS as codec-specific
//! data (`csd-0`/`csd-1`) before it can start, so it is created lazily on the
//! first access unit carrying both (a keyframe). AUs seen before that are
//! dropped (`Ok(None)`).
//! * Each `decode` call queues the AU as one input buffer and dequeues at most
//! one decoded output buffer; the host stream is low-latency with no
//! B-frames, so the steady state is one frame in, one frame out.
//! * The decoder reports the true picture rectangle through the crop keys on the
//! output format, so the emitted `Frame` is the real display size (e.g.
//! 1920x1080) even when the YUV planes are stride/slice-height padded.
use super::{Frame, VideoDecoder};
use crate::error::{NdError, Result};
use core::ffi::{c_char, c_void};
use self::sys::*;
/// `MediaCodec` hardware H.264 decoder.
pub struct MediaCodecDecoder {
/// The codec; `null` until the first keyframe primes and starts it.
codec: *mut AMediaCodec,
sps: Vec<u8>,
pps: Vec<u8>,
pts_us: i64,
}
impl MediaCodecDecoder {
/// Probe that an H.264 decoder can be created on this device, then discard
/// it (the real codec is configured lazily once the SPS/PPS arrive). Returns
/// an error — so the caller falls back to openh264 — if no AVC decoder
/// exists at all.
pub fn new() -> Result<Self> {
unsafe {
let probe = AMediaCodec_createDecoderByType(MIME.as_ptr() as *const c_char);
if probe.is_null() {
return Err(other("no AMediaCodec AVC decoder available"));
}
AMediaCodec_delete(probe);
}
Ok(Self {
codec: core::ptr::null_mut(),
sps: Vec::new(),
pps: Vec::new(),
pts_us: 0,
})
}
/// Create, configure and start the codec from the cached SPS/PPS.
fn prime(&mut self) -> Result<()> {
self.teardown();
unsafe {
let codec = AMediaCodec_createDecoderByType(MIME.as_ptr() as *const c_char);
if codec.is_null() {
return Err(other("AMediaCodec_createDecoderByType returned null"));
}
let fmt = AMediaFormat_new();
if fmt.is_null() {
AMediaCodec_delete(codec);
return Err(other("AMediaFormat_new returned null"));
}
AMediaFormat_setString(fmt, KEY_MIME.as_ptr() as _, MIME.as_ptr() as _);
// A nominal size is required; the decoder learns the real one from
// the codec-specific data and reports it back via the output format.
AMediaFormat_setInt32(fmt, KEY_WIDTH.as_ptr() as _, 1920);
AMediaFormat_setInt32(fmt, KEY_HEIGHT.as_ptr() as _, 1080);
// csd-0 = SPS, csd-1 = PPS, each as a start-code-prefixed NAL.
let csd0 = with_start_code(&self.sps);
let csd1 = with_start_code(&self.pps);
AMediaFormat_setBuffer(
fmt,
KEY_CSD0.as_ptr() as _,
csd0.as_ptr() as *const c_void,
csd0.len(),
);
AMediaFormat_setBuffer(
fmt,
KEY_CSD1.as_ptr() as _,
csd1.as_ptr() as *const c_void,
csd1.len(),
);
let st = AMediaCodec_configure(
codec,
fmt,
core::ptr::null_mut(), // no output Surface: byte-buffer mode
core::ptr::null_mut(), // no crypto
0,
);
AMediaFormat_delete(fmt);
if st != AMEDIA_OK {
AMediaCodec_delete(codec);
return Err(other(&format!("AMediaCodec_configure failed ({st})")));
}
let st = AMediaCodec_start(codec);
if st != AMEDIA_OK {
AMediaCodec_delete(codec);
return Err(other(&format!("AMediaCodec_start failed ({st})")));
}
self.codec = codec;
}
Ok(())
}
/// Queue one Annex-B access unit as an input buffer.
unsafe fn queue_input(&mut self, au: &[u8]) {
let idx = AMediaCodec_dequeueInputBuffer(self.codec, DEQUEUE_TIMEOUT_US);
if idx < 0 {
return;
}
let mut cap: usize = 0;
let buf = AMediaCodec_getInputBuffer(self.codec, idx as usize, &mut cap);
if buf.is_null() || au.len() > cap {
// Still submit an empty buffer so the index is not leaked.
let _ = AMediaCodec_queueInputBuffer(self.codec, idx as usize, 0, 0, self.pts_us, 0);
return;
}
core::ptr::copy_nonoverlapping(au.as_ptr(), buf, au.len());
let _ = AMediaCodec_queueInputBuffer(self.codec, idx as usize, 0, au.len(), self.pts_us, 0);
self.pts_us += PTS_STEP_US;
}
/// Dequeue at most one decoded output buffer and convert it to RGB.
unsafe fn pull_output(&mut self) -> Result<Option<Frame>> {
let mut info = AMediaCodecBufferInfo {
offset: 0,
size: 0,
presentationTimeUs: 0,
flags: 0,
};
let idx = AMediaCodec_dequeueOutputBuffer(self.codec, &mut info, DEQUEUE_TIMEOUT_US);
if idx < 0 {
// INFO_TRY_AGAIN_LATER / OUTPUT_FORMAT_CHANGED / BUFFERS_CHANGED: all
// mean "no frame this time"; the next call picks the format up.
return Ok(None);
}
let mut cap: usize = 0;
let buf = AMediaCodec_getOutputBuffer(self.codec, idx as usize, &mut cap);
let frame = if !buf.is_null() && info.size > 0 {
let fmt = AMediaCodec_getOutputFormat(self.codec);
let geom = read_geometry(fmt);
if !fmt.is_null() {
AMediaFormat_delete(fmt);
}
let end = (info.offset as usize)
.saturating_add(info.size as usize)
.min(cap);
let start = (info.offset as usize).min(end);
let data = core::slice::from_raw_parts(buf.add(start), end - start);
yuv420_to_rgb(data, &geom)
} else {
None
};
let _ = AMediaCodec_releaseOutputBuffer(self.codec, idx as usize, false);
Ok(frame)
}
fn teardown(&mut self) {
unsafe {
if !self.codec.is_null() {
let _ = AMediaCodec_stop(self.codec);
AMediaCodec_delete(self.codec);
self.codec = core::ptr::null_mut();
}
}
}
}
impl VideoDecoder for MediaCodecDecoder {
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
if au.is_empty() {
return Ok(None);
}
// Cache parameter sets from keyframes; (re)prime when they change.
let mut new_sps: Option<Vec<u8>> = None;
let mut new_pps: Option<Vec<u8>> = None;
for_each_nal(au, |nal| match nal.first().map(|b| b & 0x1F) {
Some(7) => new_sps = Some(nal.to_vec()),
Some(8) => new_pps = Some(nal.to_vec()),
_ => {}
});
if let (Some(sps), Some(pps)) = (new_sps, new_pps) {
if sps != self.sps || pps != self.pps || self.codec.is_null() {
self.sps = sps;
self.pps = pps;
self.prime()?;
}
}
if self.codec.is_null() {
return Ok(None); // still waiting for the first keyframe
}
unsafe {
// Feed the whole Annex-B AU (start codes included — MediaCodec wants
// the elementary stream byte format in byte-buffer mode).
self.queue_input(au);
self.pull_output()
}
}
}
impl Drop for MediaCodecDecoder {
fn drop(&mut self) {
self.teardown();
}
}
// SAFETY: the decoder is created on, and used only from, the single background
// decode thread that owns it. `AMediaCodec` handles are not shared between
// threads here (no `Sync`); moving ownership to that thread and calling it there
// is sound.
unsafe impl Send for MediaCodecDecoder {}
/// ~29.97 fps in microseconds; only needs to be monotonic.
const PTS_STEP_US: i64 = 33_367;
/// 10 ms dequeue timeout: long enough to pace one frame, short enough to keep
/// the decode loop responsive.
const DEQUEUE_TIMEOUT_US: i64 = 10_000;
// NDK format keys and MIME, as NUL-terminated byte strings (the `AMEDIAFORMAT_*`
// macros expand to exactly these literals).
const MIME: &[u8] = b"video/avc\0";
const KEY_MIME: &[u8] = b"mime\0";
const KEY_WIDTH: &[u8] = b"width\0";
const KEY_HEIGHT: &[u8] = b"height\0";
const KEY_STRIDE: &[u8] = b"stride\0";
const KEY_SLICE_HEIGHT: &[u8] = b"slice-height\0";
const KEY_COLOR_FORMAT: &[u8] = b"color-format\0";
const KEY_CROP_LEFT: &[u8] = b"crop-left\0";
const KEY_CROP_TOP: &[u8] = b"crop-top\0";
const KEY_CROP_RIGHT: &[u8] = b"crop-right\0";
const KEY_CROP_BOTTOM: &[u8] = b"crop-bottom\0";
const KEY_CSD0: &[u8] = b"csd-0\0";
const KEY_CSD1: &[u8] = b"csd-1\0";
// Android `MediaCodecInfo.CodecCapabilities` colour formats we handle.
const COLOR_FORMAT_YUV420_PLANAR: i32 = 19; // I420 (Y, U, V planes)
const COLOR_FORMAT_YUV420_SEMIPLANAR: i32 = 21; // NV12 (Y, interleaved UV)
/// Decoded output-buffer geometry pulled from the codec's output `AMediaFormat`.
struct Geometry {
color_format: i32,
stride: usize,
slice_height: usize,
/// Visible picture rectangle (display size + top-left offset).
crop_x: usize,
crop_y: usize,
disp_w: usize,
disp_h: usize,
}
/// Read the stride/slice-height/colour-format and crop rectangle from the output
/// format, filling in sane fallbacks when a key is absent.
unsafe fn read_geometry(fmt: *mut AMediaFormat) -> Geometry {
let mut width = 0i32;
let mut height = 0i32;
let mut stride = 0i32;
let mut slice_h = 0i32;
let mut color = COLOR_FORMAT_YUV420_SEMIPLANAR;
let (mut cl, mut ct, mut cr, mut cb) = (0i32, 0i32, -1i32, -1i32);
if !fmt.is_null() {
get_i32(fmt, KEY_WIDTH, &mut width);
get_i32(fmt, KEY_HEIGHT, &mut height);
get_i32(fmt, KEY_STRIDE, &mut stride);
get_i32(fmt, KEY_SLICE_HEIGHT, &mut slice_h);
get_i32(fmt, KEY_COLOR_FORMAT, &mut color);
get_i32(fmt, KEY_CROP_LEFT, &mut cl);
get_i32(fmt, KEY_CROP_TOP, &mut ct);
get_i32(fmt, KEY_CROP_RIGHT, &mut cr);
get_i32(fmt, KEY_CROP_BOTTOM, &mut cb);
}
if stride <= 0 {
stride = width;
}
if slice_h <= 0 {
slice_h = height;
}
// Crop rect is inclusive; fall back to the full coded size when absent.
let (disp_w, disp_h, crop_x, crop_y) = if cr >= cl && cb >= ct && cr >= 0 && cb >= 0 {
(
(cr - cl + 1) as usize,
(cb - ct + 1) as usize,
cl.max(0) as usize,
ct.max(0) as usize,
)
} else {
(width.max(0) as usize, height.max(0) as usize, 0, 0)
};
Geometry {
color_format: color,
stride: stride.max(0) as usize,
slice_height: slice_h.max(0) as usize,
crop_x,
crop_y,
disp_w,
disp_h,
}
}
/// Convert an I420 / NV12 output buffer to packed RGB8, cropping to the visible
/// rectangle described by `g` (BT.601 limited range, matching the other paths).
fn yuv420_to_rgb(data: &[u8], g: &Geometry) -> Option<Frame> {
let (w, h, stride, plane_h) = (g.disp_w, g.disp_h, g.stride, g.slice_height);
if w == 0 || h == 0 || stride == 0 || plane_h == 0 {
return None;
}
let y_plane = stride * plane_h;
let semiplanar = g.color_format != COLOR_FORMAT_YUV420_PLANAR;
let mut rgb = vec![0u8; w * h * 3];
for row in 0..h {
let sy = g.crop_y + row;
let y_off = sy * stride;
let out_off = row * w * 3;
let cy = sy / 2;
for col in 0..w {
let sx = g.crop_x + col;
let y = *data.get(y_off + sx).unwrap_or(&16) as i32;
let (u, v) = if semiplanar {
// NV12: interleaved UV plane after Y, half-height, full stride.
// The chroma pair for luma column `sx` sits at the even offset
// `sx & !1` within the UV row (same stride as the Y plane).
let uv_off = y_plane + cy * stride;
let base = uv_off + (sx & !1);
(
*data.get(base).unwrap_or(&128) as i32,
*data.get(base + 1).unwrap_or(&128) as i32,
)
} else {
// I420: U plane then V plane, each half-width / half-height.
let c_stride = stride / 2;
let c_plane = (stride / 2) * (plane_h / 2);
let cx = sx / 2;
let u_off = y_plane + cy * c_stride + cx;
let v_off = y_plane + c_plane + cy * c_stride + cx;
(
*data.get(u_off).unwrap_or(&128) as i32,
*data.get(v_off).unwrap_or(&128) as i32,
)
};
let c = y - 16;
let d = u - 128;
let e = v - 128;
let r = ((298 * c + 409 * e + 128) >> 8).clamp(0, 255) as u8;
let gg = ((298 * c - 100 * d - 208 * e + 128) >> 8).clamp(0, 255) as u8;
let b = ((298 * c + 516 * d + 128) >> 8).clamp(0, 255) as u8;
let o = out_off + col * 3;
rgb[o] = r;
rgb[o + 1] = gg;
rgb[o + 2] = b;
}
}
Some(Frame { w, h, rgb })
}
/// Prepend a 4-byte Annex-B start code to a raw NAL for use as csd.
fn with_start_code(nal: &[u8]) -> Vec<u8> {
let mut v = Vec::with_capacity(nal.len() + 4);
v.extend_from_slice(&[0, 0, 0, 1]);
v.extend_from_slice(nal);
v
}
/// `AMediaFormat_getInt32` wrapper that leaves `out` untouched when the key is
/// absent (so the caller's fallback stands).
unsafe fn get_i32(fmt: *mut AMediaFormat, key: &[u8], out: *mut i32) -> bool {
AMediaFormat_getInt32(fmt, key.as_ptr() as *const c_char, out)
}
/// Iterate the Annex-B NAL units in `au`, start codes excluded and trailing zero
/// bytes trimmed.
fn for_each_nal<F: FnMut(&[u8])>(au: &[u8], mut f: F) {
let n = au.len();
let mut starts: Vec<usize> = Vec::new();
let mut i = 0usize;
while i + 2 < n {
if au[i] == 0 && au[i + 1] == 0 && au[i + 2] == 1 {
starts.push(i + 3);
i += 3;
} else {
i += 1;
}
}
for k in 0..starts.len() {
let begin = starts[k];
let end = if k + 1 < starts.len() {
starts[k + 1] - 3
} else {
n
};
if begin >= end {
continue;
}
let mut nal = &au[begin..end];
while let [rest @ .., 0] = nal {
nal = rest;
}
if !nal.is_empty() {
f(nal);
}
}
}
fn other(msg: &str) -> NdError {
NdError::Other(anyhow::anyhow!(msg.to_string()))
}
/// Self-contained FFI to the NDK Media C ABI (`libmediandk`). These signatures
/// match `<media/NdkMediaCodec.h>` / `<media/NdkMediaFormat.h>` and are stable
/// since Android API 21 (`AMediaFormat_getRect`/crop keys since API 21 as well
/// for the common decoders). The `ndk`/`ndk-sys` crates wrap the same library.
#[allow(non_snake_case, non_camel_case_types)]
mod sys {
use core::ffi::{c_char, c_void};
pub type AMediaCodec = c_void;
pub type AMediaFormat = c_void;
pub type media_status_t = i32;
pub const AMEDIA_OK: media_status_t = 0;
#[repr(C)]
pub struct AMediaCodecBufferInfo {
pub offset: i32,
pub size: i32,
pub presentationTimeUs: i64,
pub flags: u32,
}
#[link(name = "mediandk")]
extern "C" {
pub fn AMediaCodec_createDecoderByType(mime_type: *const c_char) -> *mut AMediaCodec;
pub fn AMediaCodec_configure(
codec: *mut AMediaCodec,
format: *mut AMediaFormat,
surface: *mut c_void,
crypto: *mut c_void,
flags: u32,
) -> media_status_t;
pub fn AMediaCodec_start(codec: *mut AMediaCodec) -> media_status_t;
pub fn AMediaCodec_stop(codec: *mut AMediaCodec) -> media_status_t;
pub fn AMediaCodec_delete(codec: *mut AMediaCodec) -> media_status_t;
pub fn AMediaCodec_dequeueInputBuffer(codec: *mut AMediaCodec, timeout_us: i64) -> isize;
pub fn AMediaCodec_getInputBuffer(
codec: *mut AMediaCodec,
idx: usize,
out_size: *mut usize,
) -> *mut u8;
pub fn AMediaCodec_queueInputBuffer(
codec: *mut AMediaCodec,
idx: usize,
offset: i64,
size: usize,
time_us: i64,
flags: u32,
) -> media_status_t;
pub fn AMediaCodec_dequeueOutputBuffer(
codec: *mut AMediaCodec,
info: *mut AMediaCodecBufferInfo,
timeout_us: i64,
) -> isize;
pub fn AMediaCodec_getOutputBuffer(
codec: *mut AMediaCodec,
idx: usize,
out_size: *mut usize,
) -> *mut u8;
pub fn AMediaCodec_getOutputFormat(codec: *mut AMediaCodec) -> *mut AMediaFormat;
pub fn AMediaCodec_releaseOutputBuffer(
codec: *mut AMediaCodec,
idx: usize,
render: bool,
) -> media_status_t;
pub fn AMediaFormat_new() -> *mut AMediaFormat;
pub fn AMediaFormat_delete(fmt: *mut AMediaFormat) -> media_status_t;
pub fn AMediaFormat_setString(
fmt: *mut AMediaFormat,
name: *const c_char,
value: *const c_char,
);
pub fn AMediaFormat_setInt32(fmt: *mut AMediaFormat, name: *const c_char, value: i32);
pub fn AMediaFormat_setBuffer(
fmt: *mut AMediaFormat,
name: *const c_char,
data: *const c_void,
size: usize,
);
pub fn AMediaFormat_getInt32(
fmt: *mut AMediaFormat,
name: *const c_char,
out: *mut i32,
) -> bool;
}
}
+646
View File
@@ -0,0 +1,646 @@
//! Windows hardware H.264 decode via Media Foundation.
//!
//! This feeds the host's Annex-B access units into the Microsoft H.264 decoder
//! MFT (`CLSID_MSH264DecoderMFT`). When a Direct3D 11 device can be attached the
//! decode runs on the GPU video engine (DXVA2) and the decoded NV12 surface is
//! copied back to system memory; otherwise the same MFT runs as Microsoft's
//! (still heavily optimized) software decoder. Either way the output is the same
//! packed-RGB8 [`Frame`] the openh264 path produces, so the viewer is unchanged.
//!
//! The whole module is only compiled on Windows (see the `#[cfg(windows)]` gate
//! in `decode.rs`). If anything here fails at runtime, [`super::best_decoder`]
//! falls back to the portable openh264 software decoder.
use super::{Frame, VideoDecoder};
use crate::error::{NdError, Result};
use windows::core::Interface;
use windows::Win32::Foundation::{HMODULE, TRUE};
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
use windows::Win32::Graphics::Direct3D11::{
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Texture2D,
D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_VIDEO_SUPPORT,
D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
D3D11_USAGE_STAGING,
};
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT, DXGI_SAMPLE_DESC};
use windows::Win32::Media::MediaFoundation::*;
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED,
};
/// ~29.97 fps in 100-ns units. The host stream is low-latency with no B-frames,
/// so exact timing is unimportant — the decoder only needs monotonic stamps.
const HNS_PER_FRAME: i64 = 333_667;
/// Hardware (or MS-software) Media Foundation H.264 decoder.
pub struct MediaFoundationDecoder {
transform: IMFTransform,
// Direct3D plumbing kept alive for the lifetime of the decoder (hw path only).
device: Option<ID3D11Device>,
context: Option<ID3D11DeviceContext>,
_manager: Option<IMFDXGIDeviceManager>,
staging: Option<ID3D11Texture2D>,
staging_dims: (u32, u32),
/// True when DXVA hardware decode is active (output is a D3D texture).
hw: bool,
/// True when the MFT allocates its own output samples.
provides_samples: bool,
output_size: usize,
stride: usize,
/// Coded (padded, usually 16-aligned) frame size from MF_MT_FRAME_SIZE.
/// This is the NV12 *plane* geometry we read out of memory, not the picture.
coded_w: usize,
coded_h: usize,
/// True visible picture size (from the display aperture). This is what the
/// emitted `Frame.w`/`Frame.h` report, e.g. 1920x1080 even when the decoder
/// pads the coded frame to 1920x1088.
width: usize,
height: usize,
/// Top-left offset of the visible region inside the coded frame (usually 0).
crop_x: usize,
crop_y: usize,
started: bool,
pts: i64,
/// A frame pulled out while draining the MFT during a busy ProcessInput.
pending: Option<Frame>,
}
impl MediaFoundationDecoder {
/// Initialize Media Foundation and the H.264 decoder MFT. Returns an error
/// (so the caller can fall back to openh264) if MF is unavailable.
pub fn new() -> Result<Self> {
unsafe {
// COM (MTA) + MF startup. CoInitializeEx returns an HRESULT that is
// fine to ignore (S_FALSE / RPC_E_CHANGED_MODE just mean the thread
// already has an apartment). MFStartup is refcounted against the
// MFShutdown in Drop.
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
MFStartup(MF_VERSION, MFSTARTUP_FULL).map_err(mferr("MFStartup"))?;
let transform: IMFTransform =
CoCreateInstance(&CLSID_MSH264DecoderMFT, None, CLSCTX_INPROC_SERVER)
.map_err(mferr("CoCreateInstance(MSH264DecoderMFT)"))?;
// Low-latency mode: emit each frame as soon as it is decoded.
if let Ok(attrs) = transform.GetAttributes() {
let _ = attrs.SetUINT32(&MF_LOW_LATENCY, 1);
}
// Best-effort DXVA hardware acceleration.
let (device, context, manager, hw) = match init_d3d(&transform) {
Ok((d, c, m)) => (Some(d), Some(c), Some(m), true),
Err(e) => {
tracing::debug!("decode(mf): DXVA unavailable ({e}); MS software MFT");
(None, None, None, false)
}
};
// Input media type: H.264 Annex-B elementary stream.
let input = MFCreateMediaType().map_err(mferr("MFCreateMediaType(input)"))?;
input
.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)
.map_err(mferr("set input major type"))?;
input
.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)
.map_err(mferr("set input subtype H264"))?;
transform
.SetInputType(0, &input, 0)
.map_err(mferr("SetInputType"))?;
let mut me = Self {
transform,
device,
context,
_manager: manager,
staging: None,
staging_dims: (0, 0),
hw,
provides_samples: false,
output_size: 0,
stride: 0,
coded_w: 0,
coded_h: 0,
width: 0,
height: 0,
crop_x: 0,
crop_y: 0,
started: false,
pts: 0,
pending: None,
};
me.configure_output()?;
me.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0)
.map_err(mferr("NOTIFY_BEGIN_STREAMING"))?;
me.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0)
.map_err(mferr("NOTIFY_START_OF_STREAM"))?;
me.started = true;
Ok(me)
}
}
/// Whether DXVA GPU decode is active (vs. the MS software MFT).
pub fn is_hardware(&self) -> bool {
self.hw
}
/// Select an NV12 output type and read back its geometry + allocation needs.
unsafe fn configure_output(&mut self) -> Result<()> {
let mut i = 0u32;
loop {
match self.transform.GetOutputAvailableType(0, i) {
Ok(t) => {
let sub = t.GetGUID(&MF_MT_SUBTYPE).unwrap_or_default();
if sub == MFVideoFormat_NV12 {
self.transform
.SetOutputType(0, &t, 0)
.map_err(mferr("SetOutputType(NV12)"))?;
self.read_output_geometry(&t);
break;
}
i += 1;
}
Err(e) if e.code() == MF_E_NO_MORE_TYPES => {
// Nothing offered yet (common before the first keyframe sets
// the frame size) — ask for NV12 explicitly.
let t = MFCreateMediaType().map_err(mferr("MFCreateMediaType(output)"))?;
let _ = t.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video);
let _ = t.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12);
self.transform
.SetOutputType(0, &t, 0)
.map_err(mferr("SetOutputType(NV12 manual)"))?;
if let Ok(cur) = self.transform.GetOutputCurrentType(0) {
self.read_output_geometry(&cur);
}
break;
}
Err(e) => return Err(mferr("GetOutputAvailableType")(e)),
}
}
let info = self
.transform
.GetOutputStreamInfo(0)
.map_err(mferr("GetOutputStreamInfo"))?;
let provides =
(MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0 | MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES.0) as u32;
self.provides_samples = (info.dwFlags & provides) != 0;
self.output_size = info.cbSize as usize;
Ok(())
}
/// Pull the coded frame size, the true display rectangle, and the stride out
/// of a configured output media type.
///
/// The MS H.264 decoder reports the *coded* size in `MF_MT_FRAME_SIZE` — that
/// is padded up to the codec's alignment (e.g. 1920x1088 for 1080p content,
/// and larger for other resolutions), which is what produced the wrong
/// 1920x18xx figure in the stats. The real picture rectangle is carried in a
/// video-aperture blob, so we read that separately and keep the coded size
/// only for the in-memory plane layout.
unsafe fn read_output_geometry(&mut self, t: &IMFMediaType) {
// Coded (padded) plane geometry — how the NV12 bytes are laid out.
if let Ok(fs) = t.GetUINT64(&MF_MT_FRAME_SIZE) {
self.coded_w = (fs >> 32) as usize;
self.coded_h = (fs & 0xffff_ffff) as usize;
}
// True visible picture region. Prefer the minimum display aperture
// (what a correct renderer must show), then the geometric aperture, then
// the pan/scan aperture if pan/scan is actually enabled. When none is
// present the whole coded frame is the picture.
let aperture = read_aperture(t, &MF_MT_MINIMUM_DISPLAY_APERTURE)
.or_else(|| read_aperture(t, &MF_MT_GEOMETRIC_APERTURE))
.or_else(|| {
if t.GetUINT32(&MF_MT_PAN_SCAN_ENABLED).unwrap_or(0) != 0 {
read_aperture(t, &MF_MT_PAN_SCAN_APERTURE)
} else {
None
}
});
match aperture {
Some((ox, oy, aw, ah)) => {
self.crop_x = ox;
self.crop_y = oy;
// Clamp the aperture inside the coded frame so a malformed blob
// can never make the cropper read past the decoded plane.
let max_w = self.coded_w.saturating_sub(ox);
let max_h = self.coded_h.saturating_sub(oy);
self.width = if max_w > 0 { aw.min(max_w) } else { aw };
self.height = if max_h > 0 { ah.min(max_h) } else { ah };
}
None => {
self.crop_x = 0;
self.crop_y = 0;
self.width = self.coded_w;
self.height = self.coded_h;
}
}
self.stride = match t.GetUINT32(&MF_MT_DEFAULT_STRIDE) {
Ok(s) => (s as i32).unsigned_abs() as usize,
Err(_) => self.coded_w,
};
if self.stride == 0 {
self.stride = self.coded_w;
}
}
/// Wrap one Annex-B access unit in an input IMFSample.
unsafe fn make_input_sample(&mut self, au: &[u8]) -> Result<IMFSample> {
let sample = MFCreateSample().map_err(mferr("MFCreateSample"))?;
let buffer =
MFCreateMemoryBuffer(au.len() as u32).map_err(mferr("MFCreateMemoryBuffer"))?;
let mut ptr: *mut u8 = std::ptr::null_mut();
buffer
.Lock(&mut ptr, None, None)
.map_err(mferr("Lock(input)"))?;
std::ptr::copy_nonoverlapping(au.as_ptr(), ptr, au.len());
buffer.Unlock().map_err(mferr("Unlock(input)"))?;
buffer
.SetCurrentLength(au.len() as u32)
.map_err(mferr("SetCurrentLength"))?;
sample.AddBuffer(&buffer).map_err(mferr("AddBuffer"))?;
let _ = sample.SetSampleTime(self.pts);
let _ = sample.SetSampleDuration(HNS_PER_FRAME);
self.pts += HNS_PER_FRAME;
Ok(sample)
}
/// Run ProcessOutput once, materializing a frame if one is ready.
unsafe fn pull_output(&mut self) -> Result<Option<Frame>> {
loop {
let sample_slot: Option<IMFSample> = if self.provides_samples {
None
} else {
let s = MFCreateSample().map_err(mferr("MFCreateSample(output)"))?;
let size = self
.output_size
.max(self.stride * self.coded_h * 3 / 2)
.max(1);
let b = MFCreateMemoryBuffer(size as u32)
.map_err(mferr("MFCreateMemoryBuffer(output)"))?;
s.AddBuffer(&b).map_err(mferr("AddBuffer(output)"))?;
Some(s)
};
let mut buffers = [MFT_OUTPUT_DATA_BUFFER {
dwStreamID: 0,
pSample: std::mem::ManuallyDrop::new(sample_slot),
dwStatus: 0,
pEvents: std::mem::ManuallyDrop::new(None),
}];
let mut status = 0u32;
let res = self.transform.ProcessOutput(0, &mut buffers, &mut status);
// Reclaim ownership of whatever is in the slots so they drop cleanly.
let out_sample = std::mem::ManuallyDrop::take(&mut buffers[0].pSample);
let _events = std::mem::ManuallyDrop::take(&mut buffers[0].pEvents);
match res {
Ok(()) => {
let s = out_sample.ok_or_else(|| other("ProcessOutput produced no sample"))?;
return Ok(Some(self.sample_to_frame(&s)?));
}
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => return Ok(None),
Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => {
// The decoder learned the real resolution — renegotiate NV12.
self.configure_output()?;
continue;
}
Err(e) => return Err(mferr("ProcessOutput")(e)),
}
}
}
/// Convert a decoded output sample (D3D texture or system memory) to RGB.
unsafe fn sample_to_frame(&mut self, sample: &IMFSample) -> Result<Frame> {
let buffer = sample
.GetBufferByIndex(0)
.map_err(mferr("GetBufferByIndex"))?;
if self.hw {
if let Ok(dxgi) = buffer.cast::<IMFDXGIBuffer>() {
return self.dxgi_to_frame(&dxgi);
}
}
// System-memory NV12.
let mut ptr: *mut u8 = std::ptr::null_mut();
let mut cur: u32 = 0;
buffer
.Lock(&mut ptr, None, Some(&mut cur))
.map_err(mferr("Lock(output)"))?;
let stride = if self.stride == 0 {
self.coded_w
} else {
self.stride
};
// Derive the (possibly 16-aligned) plane height from the real byte count,
// never below the coded height the decoder reported.
let plane_h = if stride > 0 {
((cur as usize) * 2) / (stride * 3)
} else {
self.coded_h
}
.max(self.coded_h);
let data = std::slice::from_raw_parts(ptr, cur as usize);
// Crop the visible picture (width/height at crop_x/crop_y) out of the
// padded NV12 plane so the Frame is the true display size.
let frame = nv12_to_rgb(
data,
stride,
plane_h,
self.crop_x,
self.crop_y,
self.width,
self.height,
);
let _ = buffer.Unlock();
frame.ok_or_else(|| other("NV12→RGB conversion failed"))
}
/// Copy a decoded GPU NV12 texture to a staging texture, map it, convert.
unsafe fn dxgi_to_frame(&mut self, dxgi: &IMFDXGIBuffer) -> Result<Frame> {
let device = self.device.clone().ok_or_else(|| other("no D3D device"))?;
let context = self
.context
.clone()
.ok_or_else(|| other("no D3D context"))?;
let mut tex_ptr: *mut core::ffi::c_void = std::ptr::null_mut();
dxgi.GetResource(&ID3D11Texture2D::IID, &mut tex_ptr)
.map_err(mferr("IMFDXGIBuffer::GetResource"))?;
if tex_ptr.is_null() {
return Err(other("decoded texture was null"));
}
let src = ID3D11Texture2D::from_raw(tex_ptr);
let sub = dxgi.GetSubresourceIndex().unwrap_or(0);
let mut desc: D3D11_TEXTURE2D_DESC = std::mem::zeroed();
src.GetDesc(&mut desc);
self.ensure_staging(&device, desc.Width, desc.Height, desc.Format)?;
let staging = self
.staging
.as_ref()
.ok_or_else(|| other("no staging texture"))?
.clone();
// Copy the one decoded subresource out of the decoder's texture array.
context.CopySubresourceRegion(&staging, 0, 0, 0, 0, &src, sub, None);
let mut mapped: D3D11_MAPPED_SUBRESOURCE = std::mem::zeroed();
context
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))
.map_err(mferr("Map(staging)"))?;
let row_pitch = mapped.RowPitch as usize;
// The staging texture height is the coded (padded) plane height.
let plane_h = desc.Height as usize;
let total = row_pitch * plane_h * 3 / 2;
let data = std::slice::from_raw_parts(mapped.pData as *const u8, total);
// Crop to the true visible picture out of the padded GPU NV12 surface.
let frame = nv12_to_rgb(
data,
row_pitch,
plane_h,
self.crop_x,
self.crop_y,
self.width,
self.height,
);
context.Unmap(&staging, 0);
frame.ok_or_else(|| other("NV12→RGB conversion failed"))
}
/// (Re)create the CPU-readable staging texture when the size changes.
unsafe fn ensure_staging(
&mut self,
device: &ID3D11Device,
w: u32,
h: u32,
fmt: DXGI_FORMAT,
) -> Result<()> {
if self.staging.is_some() && self.staging_dims == (w, h) {
return Ok(());
}
let desc = D3D11_TEXTURE2D_DESC {
Width: w,
Height: h,
MipLevels: 1,
ArraySize: 1,
Format: fmt,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_STAGING,
BindFlags: 0,
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
MiscFlags: 0,
};
let mut tex: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&desc, None, Some(&mut tex))
.map_err(mferr("CreateTexture2D(staging)"))?;
self.staging = tex;
self.staging_dims = (w, h);
Ok(())
}
}
impl VideoDecoder for MediaFoundationDecoder {
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
if au.is_empty() {
return Ok(None);
}
unsafe {
let sample = self.make_input_sample(au)?;
// Feed the AU. If the MFT is momentarily full, drain its output and
// retry (keeping the most recent frame for this call).
let mut guard = 0;
loop {
match self.transform.ProcessInput(0, &sample, 0) {
Ok(()) => break,
Err(e) if e.code() == MF_E_NOTACCEPTING => {
if let Some(f) = self.pull_output()? {
self.pending = Some(f);
}
guard += 1;
if guard > 16 {
return Err(other("MFT refused input repeatedly"));
}
}
Err(e) => return Err(mferr("ProcessInput")(e)),
}
}
// Pull the decoded picture(s). With zero-latency/no-B-frame input the
// common case is exactly one frame per AU.
let mut out = self.pending.take();
while let Some(f) = self.pull_output()? {
out = Some(f);
}
Ok(out)
}
}
}
impl Drop for MediaFoundationDecoder {
fn drop(&mut self) {
unsafe {
if self.started {
let _ = self
.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0);
let _ = self
.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);
}
// Balances the MFStartup in `new` (both are refcounted).
let _ = MFShutdown();
}
}
}
// SAFETY: a MediaFoundationDecoder is created on, and used only from, the single
// background network thread that owns it (the viewer's decode loop). COM is
// initialized multithreaded (MTA) so the MFT is agile, and the D3D11 device is
// marked multithread-protected, so moving the decoder to that thread and calling
// it there is sound. It is never shared between threads (no `Sync`).
unsafe impl Send for MediaFoundationDecoder {}
/// Create a D3D11 device + DXGI device manager and hand it to the decoder to
/// enable DXVA2 hardware decode. Any failure leaves the MFT in software mode.
unsafe fn init_d3d(
transform: &IMFTransform,
) -> Result<(ID3D11Device, ID3D11DeviceContext, IMFDXGIDeviceManager)> {
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice(
None,
D3D_DRIVER_TYPE_HARDWARE,
HMODULE(std::ptr::null_mut()),
D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT,
None,
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
.map_err(mferr("D3D11CreateDevice"))?;
let device = device.ok_or_else(|| other("D3D11CreateDevice gave no device"))?;
let context = context.ok_or_else(|| other("D3D11CreateDevice gave no context"))?;
// MF calls the device from its own worker threads.
if let Ok(mt) = device.cast::<ID3D11Multithread>() {
let _ = mt.SetMultithreadProtected(TRUE);
}
let mut token = 0u32;
let mut manager: Option<IMFDXGIDeviceManager> = None;
MFCreateDXGIDeviceManager(&mut token, &mut manager)
.map_err(mferr("MFCreateDXGIDeviceManager"))?;
let manager = manager.ok_or_else(|| other("no DXGI device manager"))?;
manager
.ResetDevice(&device, token)
.map_err(mferr("IMFDXGIDeviceManager::ResetDevice"))?;
// The ulParam for SET_D3D_MANAGER is the IUnknown* of the device manager.
let param = manager.as_raw() as usize;
transform
.ProcessMessage(MFT_MESSAGE_SET_D3D_MANAGER, param)
.map_err(mferr("MFT_MESSAGE_SET_D3D_MANAGER"))?;
Ok((device, context, manager))
}
/// NV12 → packed RGB8 (BT.601 limited range, matching the openh264 path),
/// cropping the visible `w`x`h` picture at offset (`off_x`, `off_y`) out of the
/// (possibly padded) coded plane described by `stride` x `plane_h`.
///
/// `plane_h` is the coded/aligned luma height: the interleaved UV plane starts
/// at `stride * plane_h`, so it must be the padded height, not the visible one.
#[allow(clippy::too_many_arguments)]
fn nv12_to_rgb(
data: &[u8],
stride: usize,
plane_h: usize,
off_x: usize,
off_y: usize,
w: usize,
h: usize,
) -> Option<Frame> {
if w == 0 || h == 0 || stride == 0 {
return None;
}
let uv_base = stride * plane_h;
let mut rgb = vec![0u8; w * h * 3];
for row in 0..h {
let sy = off_y + row; // source luma row inside the coded plane
let y_off = sy * stride;
let uv_off = uv_base + (sy / 2) * stride;
let out_off = row * w * 3;
for col in 0..w {
let sx = off_x + col; // source luma column inside the coded plane
let y = *data.get(y_off + sx).unwrap_or(&16) as i32;
let uvx = sx & !1; // chroma pair aligned to the even luma column
let u = *data.get(uv_off + uvx).unwrap_or(&128) as i32;
let v = *data.get(uv_off + uvx + 1).unwrap_or(&128) as i32;
let c = y - 16;
let d = u - 128;
let e = v - 128;
let r = ((298 * c + 409 * e + 128) >> 8).clamp(0, 255) as u8;
let g = ((298 * c - 100 * d - 208 * e + 128) >> 8).clamp(0, 255) as u8;
let b = ((298 * c + 516 * d + 128) >> 8).clamp(0, 255) as u8;
let o = out_off + col * 3;
rgb[o] = r;
rgb[o + 1] = g;
rgb[o + 2] = b;
}
}
Some(Frame { w, h, rgb })
}
/// Read an `MFVideoArea` aperture blob (display / geometric / pan-scan) off a
/// media type and return `(offset_x, offset_y, width, height)` in whole pixels,
/// or `None` when the attribute is absent or degenerate.
///
/// `MFVideoArea` is a fixed 16-byte POD (`MFOffset` x,y + `SIZE`), so we read it
/// straight into the struct. The `MFOffset` fractional part is ignored: H.264
/// display apertures are always integer-pixel aligned.
unsafe fn read_aperture(
t: &IMFMediaType,
key: *const windows::core::GUID,
) -> Option<(usize, usize, usize, usize)> {
let mut area = MFVideoArea::default();
let buf = std::slice::from_raw_parts_mut(
(&mut area as *mut MFVideoArea).cast::<u8>(),
std::mem::size_of::<MFVideoArea>(),
);
t.GetBlob(key, buf, None).ok()?;
let w = area.Area.cx;
let h = area.Area.cy;
if w <= 0 || h <= 0 {
return None;
}
let ox = area.OffsetX.value.max(0) as usize;
let oy = area.OffsetY.value.max(0) as usize;
Some((ox, oy, w as usize, h as usize))
}
/// Build a context-tagged error mapper for `windows::core::Error`.
fn mferr(ctx: &'static str) -> impl Fn(windows::core::Error) -> NdError {
move |e| NdError::Other(anyhow::anyhow!("MediaFoundation {ctx}: {e}"))
}
fn other(msg: &str) -> NdError {
NdError::Other(anyhow::anyhow!(msg.to_string()))
}
+287
View File
@@ -0,0 +1,287 @@
//! Linux hardware H.264 decode via VAAPI, driven through FFmpeg/libavcodec.
//!
//! Pure libva decoding would mean parsing the H.264 bitstream down to slice
//! headers and hand-building `VAPictureParameterBufferH264` / reference lists;
//! libavcodec already does all of that and exposes VAAPI as a hardware
//! accelerator, so this backend opens the libavcodec H.264 decoder with a VAAPI
//! `hw_device_ctx`. Decode runs on the GPU (Intel/AMD/`iHD`/`radeonsi` via
//! `/dev/dri`), the decoded surface is transferred to system memory (NV12), and
//! swscale converts it to the same packed-RGB8 [`Frame`] the openh264 path
//! produces, so the viewer is unchanged.
//!
//! The module is only compiled on Linux (see the `#[cfg(target_os = "linux")]`
//! gate in `decode.rs`). If VAAPI is unavailable at runtime —
//! `av_hwdevice_ctx_create` fails with no usable DRI device — `new` returns an
//! error and [`super::best_decoder`] falls back to the portable openh264
//! decoder, so headless / GPU-less Linux boxes still work.
use super::{Frame, VideoDecoder};
use crate::error::{NdError, Result};
use core::ffi::{c_int, c_void};
use ffmpeg_next::ffi::*;
/// FFmpeg/VAAPI hardware H.264 decoder.
pub struct VaapiDecoder {
ctx: *mut AVCodecContext,
hw_device: *mut AVBufferRef,
packet: *mut AVPacket,
hw_frame: *mut AVFrame,
sw_frame: *mut AVFrame,
sws: *mut SwsContext,
sws_dims: (i32, i32, i32), // (w, h, src pixel format) the cached scaler is for
pts: i64,
}
impl VaapiDecoder {
pub fn new() -> Result<Self> {
unsafe {
// Safe to call repeatedly; wires up codec registration + logging.
let _ = ffmpeg_next::init();
let codec = avcodec_find_decoder(AVCodecID::AV_CODEC_ID_H264);
if codec.is_null() {
return Err(other("libavcodec has no H.264 decoder"));
}
let ctx = avcodec_alloc_context3(codec);
if ctx.is_null() {
return Err(other("avcodec_alloc_context3 failed"));
}
// Create the VAAPI device (default DRI render node) and attach it.
let mut hw_device: *mut AVBufferRef = core::ptr::null_mut();
let rc = av_hwdevice_ctx_create(
&mut hw_device,
AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
core::ptr::null(),
core::ptr::null_mut(),
0,
);
let mut ctx = ctx;
if rc < 0 || hw_device.is_null() {
avcodec_free_context(&mut ctx);
return Err(other(&format!(
"av_hwdevice_ctx_create(VAAPI) failed ({rc})"
)));
}
(*ctx).hw_device_ctx = av_buffer_ref(hw_device);
(*ctx).get_format = Some(get_hw_format);
let rc = avcodec_open2(ctx, codec, core::ptr::null_mut());
if rc < 0 {
av_buffer_unref(&mut hw_device);
avcodec_free_context(&mut ctx);
return Err(other(&format!("avcodec_open2 failed ({rc})")));
}
let mut packet = av_packet_alloc();
let mut hw_frame = av_frame_alloc();
let mut sw_frame = av_frame_alloc();
if packet.is_null() || hw_frame.is_null() || sw_frame.is_null() {
if !packet.is_null() {
av_packet_free(&mut packet);
}
if !hw_frame.is_null() {
av_frame_free(&mut hw_frame);
}
if !sw_frame.is_null() {
av_frame_free(&mut sw_frame);
}
av_buffer_unref(&mut hw_device);
avcodec_free_context(&mut ctx);
return Err(other("av_frame_alloc / av_packet_alloc failed"));
}
Ok(Self {
ctx,
hw_device,
packet,
hw_frame,
sw_frame,
sws: core::ptr::null_mut(),
sws_dims: (0, 0, -1),
pts: 0,
})
}
}
/// Transfer a decoded frame to system memory (if it is a GPU surface), crop
/// it to the display rectangle, and swscale it to packed RGB8.
unsafe fn frame_to_rgb(&mut self) -> Result<Option<Frame>> {
// A VAAPI surface must be pulled down to a software NV12 frame first;
// a software frame (fallback path) is used as-is.
let src: *mut AVFrame =
if (*self.hw_frame).format == AVPixelFormat::AV_PIX_FMT_VAAPI as c_int {
av_frame_unref(self.sw_frame);
let rc = av_hwframe_transfer_data(self.sw_frame, self.hw_frame, 0);
if rc < 0 {
return Err(other(&format!("av_hwframe_transfer_data failed ({rc})")));
}
// Carry crop/colour metadata across from the hardware frame.
av_frame_copy_props(self.sw_frame, self.hw_frame);
self.sw_frame
} else {
self.hw_frame
};
// Apply the H.264 display crop so width/height are the true picture size
// (e.g. 1920x1080) rather than the 16-aligned coded size.
let _ = av_frame_apply_cropping(src, 1 /* AV_FRAME_CROP_UNALIGNED */);
let w = (*src).width;
let h = (*src).height;
let src_fmt = (*src).format;
if w <= 0 || h <= 0 {
return Ok(None);
}
// (Re)create the scaler when the geometry or source format changes.
if self.sws.is_null() || self.sws_dims != (w, h, src_fmt) {
if !self.sws.is_null() {
sws_freeContext(self.sws);
}
self.sws = sws_getContext(
w,
h,
core::mem::transmute::<c_int, AVPixelFormat>(src_fmt),
w,
h,
AVPixelFormat::AV_PIX_FMT_RGB24,
SWS_BILINEAR as c_int,
core::ptr::null_mut(), // srcFilter: *mut SwsFilter
core::ptr::null_mut(), // dstFilter: *mut SwsFilter
core::ptr::null(), // param: *const c_double
);
if self.sws.is_null() {
return Err(other("sws_getContext failed"));
}
self.sws_dims = (w, h, src_fmt);
}
let mut rgb = vec![0u8; (w as usize) * (h as usize) * 3];
let dst_data: [*mut u8; 4] = [
rgb.as_mut_ptr(),
core::ptr::null_mut(),
core::ptr::null_mut(),
core::ptr::null_mut(),
];
let dst_linesize: [c_int; 4] = [w * 3, 0, 0, 0];
let scaled = sws_scale(
self.sws,
(*src).data.as_ptr() as *const *const u8,
(*src).linesize.as_ptr(),
0,
h,
dst_data.as_ptr(),
dst_linesize.as_ptr(),
);
if scaled <= 0 {
return Ok(None);
}
Ok(Some(Frame {
w: w as usize,
h: h as usize,
rgb,
}))
}
}
impl VideoDecoder for VaapiDecoder {
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
if au.is_empty() {
return Ok(None);
}
unsafe {
// Point the reusable packet at this AU. The data is not reference
// counted (no `buf`), so libavcodec copies what it needs during
// `avcodec_send_packet`; the slice only has to outlive this call.
av_packet_unref(self.packet);
(*self.packet).data = au.as_ptr() as *mut u8;
(*self.packet).size = au.len() as c_int;
(*self.packet).pts = self.pts;
(*self.packet).dts = self.pts;
self.pts += 1;
let mut out: Option<Frame> = None;
let mut sret = avcodec_send_packet(self.ctx, self.packet);
if sret < 0 {
// Decoder is full: drain a frame, keep it, then resend.
av_frame_unref(self.hw_frame);
if avcodec_receive_frame(self.ctx, self.hw_frame) == 0 {
out = self.frame_to_rgb()?;
}
sret = avcodec_send_packet(self.ctx, self.packet);
}
// Detach our borrowed buffer from the packet before it is reused.
(*self.packet).data = core::ptr::null_mut();
(*self.packet).size = 0;
if sret < 0 && out.is_none() {
// EAGAIN / not-yet-ready before the first keyframe: no frame.
return Ok(None);
}
av_frame_unref(self.hw_frame);
if avcodec_receive_frame(self.ctx, self.hw_frame) == 0 {
out = self.frame_to_rgb()?;
}
Ok(out)
}
}
}
impl Drop for VaapiDecoder {
fn drop(&mut self) {
unsafe {
if !self.sws.is_null() {
sws_freeContext(self.sws);
}
if !self.sw_frame.is_null() {
av_frame_free(&mut self.sw_frame);
}
if !self.hw_frame.is_null() {
av_frame_free(&mut self.hw_frame);
}
if !self.packet.is_null() {
av_packet_free(&mut self.packet);
}
if !self.hw_device.is_null() {
av_buffer_unref(&mut self.hw_device);
}
if !self.ctx.is_null() {
avcodec_free_context(&mut self.ctx);
}
}
}
}
// SAFETY: the decoder is created on, and used only from, the single background
// decode thread that owns it. The libavcodec context and its frames/packet are
// never shared across threads here (no `Sync`); moving ownership to that thread
// and calling it there is sound.
unsafe impl Send for VaapiDecoder {}
/// `get_format` callback: prefer the VAAPI hardware pixel format when the
/// decoder offers it, otherwise fall back to the first (software) format so a
/// GPU-less box still decodes.
unsafe extern "C" fn get_hw_format(
_ctx: *mut AVCodecContext,
mut fmts: *const AVPixelFormat,
) -> AVPixelFormat {
let first = if fmts.is_null() {
AVPixelFormat::AV_PIX_FMT_NONE
} else {
*fmts
};
while !fmts.is_null() && *fmts != AVPixelFormat::AV_PIX_FMT_NONE {
if *fmts == AVPixelFormat::AV_PIX_FMT_VAAPI {
return AVPixelFormat::AV_PIX_FMT_VAAPI;
}
fmts = fmts.add(1);
}
first
}
fn other(msg: &str) -> NdError {
NdError::Other(anyhow::anyhow!(msg.to_string()))
}
+539
View File
@@ -0,0 +1,539 @@
//! Apple hardware H.264 decode via VideoToolbox (`VTDecompressionSession`).
//!
//! On macOS and iOS this feeds the host's Annex-B access units into a
//! `VTDecompressionSession`, which runs on the platform video decode hardware
//! (the same engine `AVFoundation` uses). The decoder emits `CVPixelBuffer`s in
//! 32-bit BGRA — VideoToolbox does the YUV→RGB colour conversion on the GPU for
//! us — which we repack into the same packed-RGB8 [`Frame`] the openh264 path
//! produces, so the viewer is unchanged.
//!
//! This whole module is only compiled on Apple targets (see the
//! `#[cfg(any(target_os = "macos", target_os = "ios"))]` gate in `decode.rs`).
//! If anything here fails at runtime, [`super::best_decoder`] falls back to the
//! portable openh264 software decoder.
//!
//! ## Design
//! * VideoToolbox needs a `CMVideoFormatDescription` built from the stream's SPS
//! and PPS before a session can exist, so the session is created lazily on the
//! first access unit that carries both (a keyframe). AUs seen before that are
//! dropped (`Ok(None)`), exactly like the other backends while they prime.
//! * The host sends Annex-B (start-code delimited) NAL units; VideoToolbox wants
//! AVCC (4-byte length-prefixed) NALs in the `CMSampleBuffer`, so each AU is
//! repacked. Parameter-set NALs go into the format description, not the sample.
//! * Decode is driven synchronously: we submit the frame and immediately call
//! `VTDecompressionSessionWaitForAsynchronousFrames`, so the output callback
//! has run (and stored the frame in our heap-pinned [`Shared`] slot) by the
//! time `decode` returns.
//! * `CVPixelBufferGetWidth`/`Height` already return the *display* size (the
//! decoder applies the SPS crop), so there is no padded-dimension problem to
//! correct here the way there is on the Media Foundation path.
use super::{Frame, VideoDecoder};
use crate::error::{NdError, Result};
use core::ffi::c_void;
use core_foundation::base::TCFType;
use core_foundation::dictionary::CFDictionary;
use core_foundation::number::CFNumber;
use core_foundation::string::CFString;
use self::sys::*;
/// VideoToolbox hardware H.264 decoder.
pub struct VideoToolboxDecoder {
/// Decompression session; `null` until the first keyframe primes it.
session: VTDecompressionSessionRef,
/// Format description built from the current SPS/PPS; owned (+1 ref).
format: CMFormatDescriptionRef,
/// Cached parameter sets, so we only rebuild the session when they change.
sps: Vec<u8>,
pps: Vec<u8>,
/// Heap-pinned output slot the C callback writes the decoded frame into.
/// Boxed so its address is stable for the `refcon` handed to the session.
shared: Box<Shared>,
pts: i64,
}
/// Output slot shared with the decompression callback via a raw `refcon`.
struct Shared {
frame: Option<Frame>,
}
impl VideoToolboxDecoder {
/// Prepare an (empty) decoder. The real session is built lazily once the
/// first SPS+PPS arrive, so this never fails on its own.
pub fn new() -> Result<Self> {
Ok(Self {
session: core::ptr::null_mut(),
format: core::ptr::null_mut(),
sps: Vec::new(),
pps: Vec::new(),
shared: Box::new(Shared { frame: None }),
pts: 0,
})
}
/// (Re)build the format description and decompression session from the
/// current `self.sps`/`self.pps`.
fn rebuild_session(&mut self) -> Result<()> {
self.teardown();
// CMVideoFormatDescription from the two parameter sets (raw NAL bytes,
// no start codes), with a 4-byte NAL length prefix for the AVCC samples.
let ptrs: [*const u8; 2] = [self.sps.as_ptr(), self.pps.as_ptr()];
let sizes: [usize; 2] = [self.sps.len(), self.pps.len()];
let mut format: CMFormatDescriptionRef = core::ptr::null_mut();
let status = unsafe {
CMVideoFormatDescriptionCreateFromH264ParameterSets(
core::ptr::null(),
2,
ptrs.as_ptr(),
sizes.as_ptr(),
4,
&mut format,
)
};
if status != 0 || format.is_null() {
return Err(other(&format!(
"CMVideoFormatDescriptionCreateFromH264ParameterSets failed ({status})"
)));
}
self.format = format;
// Ask VideoToolbox to hand us ready-to-blit 32-bit BGRA pixel buffers.
let key = unsafe { CFString::wrap_under_get_rule(kCVPixelBufferPixelFormatTypeKey) };
let val = CFNumber::from(kCVPixelFormatType_32BGRA);
let attrs = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), val.as_CFType())]);
let mut record = VTDecompressionOutputCallbackRecord {
decompressionOutputCallback: Some(decompression_callback),
decompressionOutputRefCon: (&mut *self.shared) as *mut Shared as *mut c_void,
};
let mut session: VTDecompressionSessionRef = core::ptr::null_mut();
let status = unsafe {
VTDecompressionSessionCreate(
core::ptr::null(),
self.format,
core::ptr::null(), // default decoder (hardware when available)
attrs.as_concrete_TypeRef() as _, // destination buffer attributes
&record,
&mut session,
)
};
// `attrs`/`record` are retained/copied by the call above; drop them now.
let _ = record.decompressionOutputRefCon;
if status != 0 || session.is_null() {
return Err(other(&format!(
"VTDecompressionSessionCreate failed ({status})"
)));
}
self.session = session;
Ok(())
}
/// Decode one AVCC-packed sample through the session, returning its frame.
fn decode_sample(&mut self, avcc: &[u8]) -> Result<Option<Frame>> {
if self.session.is_null() || avcc.is_empty() {
return Ok(None);
}
// Wrap the AVCC bytes in a CMBlockBuffer (which owns a private copy).
let mut block: CMBlockBufferRef = core::ptr::null_mut();
let status = unsafe {
CMBlockBufferCreateWithMemoryBlock(
core::ptr::null(),
core::ptr::null_mut(),
avcc.len(),
core::ptr::null(), // default block allocator
core::ptr::null(),
0,
avcc.len(),
kCMBlockBufferAssureMemoryNowFlag,
&mut block,
)
};
if status != 0 || block.is_null() {
return Err(other(&format!(
"CMBlockBufferCreateWithMemoryBlock ({status})"
)));
}
let status = unsafe {
CMBlockBufferReplaceDataBytes(avcc.as_ptr() as *const c_void, block, 0, avcc.len())
};
if status != 0 {
unsafe { CFRelease(block as _) };
return Err(other(&format!("CMBlockBufferReplaceDataBytes ({status})")));
}
// Build the sample buffer with timing and size.
let timing = CMSampleTimingInfo {
duration: CMTime {
value: HNS_PER_FRAME,
timescale: 10_000_000,
flags: K_CMTIME_FLAGS_VALID,
epoch: 0,
},
presentationTimeStamp: CMTime {
value: self.pts,
timescale: 10_000_000,
flags: K_CMTIME_FLAGS_VALID,
epoch: 0,
},
decodeTimeStamp: CMTime {
value: 0,
timescale: 0,
flags: 0,
epoch: 0,
},
};
self.pts += HNS_PER_FRAME;
let sizes = [avcc.len()];
let mut sample: CMSampleBufferRef = core::ptr::null_mut();
let status = unsafe {
CMSampleBufferCreateReady(
core::ptr::null(),
block,
self.format,
1,
1,
&timing,
1,
sizes.as_ptr(),
&mut sample,
)
};
unsafe { CFRelease(block as _) };
if status != 0 || sample.is_null() {
return Err(other(&format!("CMSampleBufferCreateReady ({status})")));
}
// Submit synchronously: the callback runs before Wait returns.
self.shared.frame = None;
let mut info_flags: u32 = 0;
let status = unsafe {
VTDecompressionSessionDecodeFrame(
self.session,
sample,
0,
core::ptr::null_mut(),
&mut info_flags,
)
};
if status == 0 {
unsafe { VTDecompressionSessionWaitForAsynchronousFrames(self.session) };
}
unsafe { CFRelease(sample as _) };
if status != 0 {
return Err(other(&format!(
"VTDecompressionSessionDecodeFrame ({status})"
)));
}
Ok(self.shared.frame.take())
}
fn teardown(&mut self) {
unsafe {
if !self.session.is_null() {
VTDecompressionSessionInvalidate(self.session);
CFRelease(self.session as _);
self.session = core::ptr::null_mut();
}
if !self.format.is_null() {
CFRelease(self.format as _);
self.format = core::ptr::null_mut();
}
}
}
}
impl VideoDecoder for VideoToolboxDecoder {
fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>> {
if au.is_empty() {
return Ok(None);
}
// Split the Annex-B AU, pulling out parameter sets and repacking the
// decodable NALs as AVCC (4-byte big-endian length prefixes).
let mut new_sps: Option<Vec<u8>> = None;
let mut new_pps: Option<Vec<u8>> = None;
let mut avcc: Vec<u8> = Vec::with_capacity(au.len() + 16);
for_each_nal(au, |nal| {
match nal.first().map(|b| b & 0x1F) {
Some(7) => new_sps = Some(nal.to_vec()), // SPS
Some(8) => new_pps = Some(nal.to_vec()), // PPS
Some(9) => {} // AUD: drop
Some(_) => {
avcc.extend_from_slice(&(nal.len() as u32).to_be_bytes());
avcc.extend_from_slice(nal);
}
None => {}
}
});
// A keyframe carries fresh parameter sets; rebuild the session when they
// change (first keyframe, or a resolution/profile switch mid-stream).
if let (Some(sps), Some(pps)) = (new_sps, new_pps) {
if sps != self.sps || pps != self.pps || self.session.is_null() {
self.sps = sps;
self.pps = pps;
self.rebuild_session()?;
}
}
self.decode_sample(&avcc)
}
}
impl Drop for VideoToolboxDecoder {
fn drop(&mut self) {
self.teardown();
}
}
// SAFETY: the decoder is created on, and only ever touched from, the single
// background decode thread that owns it (the viewer's network loop). The
// VideoToolbox session is used synchronously and never shared across threads
// (no `Sync`), and the `Shared` slot the callback writes is reached only while
// that same thread is blocked inside `WaitForAsynchronousFrames`.
unsafe impl Send for VideoToolboxDecoder {}
/// ~29.97 fps in 100-ns units; only needs to be monotonic for this stream.
const HNS_PER_FRAME: i64 = 333_667;
/// Iterate the Annex-B NAL units in `au`, trimming trailing zero bytes (which
/// belong to the next start code / are RBSP padding) so each slice is the raw
/// NAL, start-code excluded.
fn for_each_nal<F: FnMut(&[u8])>(au: &[u8], mut f: F) {
let n = au.len();
// Payload start offset of every `00 00 01` start code (a 4-byte start code
// `00 00 00 01` is matched on its trailing three bytes, the extra leading
// zero falling into the previous NAL's trailing-zero trim).
let mut starts: Vec<usize> = Vec::new();
let mut i = 0usize;
while i + 2 < n {
if au[i] == 0 && au[i + 1] == 0 && au[i + 2] == 1 {
starts.push(i + 3);
i += 3;
} else {
i += 1;
}
}
for k in 0..starts.len() {
let begin = starts[k];
let end = if k + 1 < starts.len() {
starts[k + 1] - 3
} else {
n
};
if begin >= end {
continue;
}
let mut nal = &au[begin..end];
while let [rest @ .., 0] = nal {
nal = rest;
}
if !nal.is_empty() {
f(nal);
}
}
}
/// VideoToolbox output callback: convert the decoded BGRA pixel buffer to a
/// packed-RGB8 [`Frame`] and stash it in the shared slot pointed to by `refcon`.
extern "C" fn decompression_callback(
refcon: *mut c_void,
_source_frame_refcon: *mut c_void,
status: i32,
_info_flags: u32,
image_buffer: CVImageBufferRef,
_pts: CMTime,
_duration: CMTime,
) {
if refcon.is_null() || status != 0 || image_buffer.is_null() {
return;
}
let shared = unsafe { &mut *(refcon as *mut Shared) };
let pb = image_buffer; // CVImageBufferRef and CVPixelBufferRef are the same handle
unsafe {
if CVPixelBufferLockBaseAddress(pb, K_CVPIXELBUFFER_LOCK_READ_ONLY) != 0 {
return;
}
let w = CVPixelBufferGetWidth(pb);
let h = CVPixelBufferGetHeight(pb);
let stride = CVPixelBufferGetBytesPerRow(pb);
let base = CVPixelBufferGetBaseAddress(pb) as *const u8;
if !base.is_null() && w > 0 && h > 0 && stride >= w * 4 {
let src = core::slice::from_raw_parts(base, stride * h);
let mut rgb = vec![0u8; w * h * 3];
for row in 0..h {
let srow = row * stride;
let drow = row * w * 3;
for col in 0..w {
let s = srow + col * 4; // BGRA
let d = drow + col * 3;
rgb[d] = src[s + 2]; // R
rgb[d + 1] = src[s + 1]; // G
rgb[d + 2] = src[s]; // B
}
}
shared.frame = Some(Frame { w, h, rgb });
}
CVPixelBufferUnlockBaseAddress(pb, K_CVPIXELBUFFER_LOCK_READ_ONLY);
}
}
fn other(msg: &str) -> NdError {
NdError::Other(anyhow::anyhow!(msg.to_string()))
}
/// Minimal, self-contained FFI surface for the CoreMedia / CoreVideo /
/// VideoToolbox C APIs this decoder uses. These ABIs are stable across macOS and
/// iOS releases; the `CFDictionaryRef`/`CFStringRef` types are shared with the
/// `core-foundation` crate so the attribute dictionary bridges cleanly.
#[allow(non_snake_case, non_upper_case_globals)]
mod sys {
use core::ffi::c_void;
use core_foundation_sys::base::{CFAllocatorRef, CFRelease as cf_release, CFTypeRef};
use core_foundation_sys::dictionary::CFDictionaryRef;
use core_foundation_sys::string::CFStringRef;
pub type OSStatus = i32;
pub type CMFormatDescriptionRef = *mut c_void;
pub type CMVideoFormatDescriptionRef = CMFormatDescriptionRef;
pub type CMBlockBufferRef = *mut c_void;
pub type CMSampleBufferRef = *mut c_void;
pub type VTDecompressionSessionRef = *mut c_void;
pub type CVImageBufferRef = *mut c_void;
pub type CVPixelBufferRef = *mut c_void;
/// `kCVPixelFormatType_32BGRA` ('BGRA').
pub const kCVPixelFormatType_32BGRA: i32 = 0x4247_5241;
/// `kCVPixelBufferLock_ReadOnly`.
pub const K_CVPIXELBUFFER_LOCK_READ_ONLY: u64 = 0x0000_0001;
/// `kCMTimeFlags_Valid`.
pub const K_CMTIME_FLAGS_VALID: u32 = 0x0000_0001;
/// `kCMBlockBufferAssureMemoryNowFlag`.
pub const kCMBlockBufferAssureMemoryNowFlag: u32 = 0x0000_0001;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CMTime {
pub value: i64,
pub timescale: i32,
pub flags: u32,
pub epoch: i64,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CMSampleTimingInfo {
pub duration: CMTime,
pub presentationTimeStamp: CMTime,
pub decodeTimeStamp: CMTime,
}
pub type VTDecompressionOutputCallback = extern "C" fn(
refcon: *mut c_void,
source_frame_refcon: *mut c_void,
status: OSStatus,
info_flags: u32,
image_buffer: CVImageBufferRef,
pts: CMTime,
duration: CMTime,
);
#[repr(C)]
pub struct VTDecompressionOutputCallbackRecord {
pub decompressionOutputCallback: Option<VTDecompressionOutputCallback>,
pub decompressionOutputRefCon: *mut c_void,
}
/// Re-export CoreFoundation's `CFRelease` under the name this module uses.
#[inline]
pub unsafe fn CFRelease(obj: CFTypeRef) {
cf_release(obj)
}
#[link(name = "CoreMedia", kind = "framework")]
extern "C" {
pub fn CMVideoFormatDescriptionCreateFromH264ParameterSets(
allocator: CFAllocatorRef,
parameter_set_count: usize,
parameter_set_pointers: *const *const u8,
parameter_set_sizes: *const usize,
nal_unit_header_length: i32,
format_description_out: *mut CMFormatDescriptionRef,
) -> OSStatus;
pub fn CMBlockBufferCreateWithMemoryBlock(
structure_allocator: CFAllocatorRef,
memory_block: *mut c_void,
block_length: usize,
block_allocator: CFAllocatorRef,
custom_block_source: *const c_void,
offset_to_data: usize,
data_length: usize,
flags: u32,
block_buffer_out: *mut CMBlockBufferRef,
) -> OSStatus;
pub fn CMBlockBufferReplaceDataBytes(
source_bytes: *const c_void,
destination_buffer: CMBlockBufferRef,
offset_into_destination: usize,
data_length: usize,
) -> OSStatus;
pub fn CMSampleBufferCreateReady(
allocator: CFAllocatorRef,
data_buffer: CMBlockBufferRef,
format_description: CMFormatDescriptionRef,
num_samples: isize,
num_sample_timing_entries: isize,
sample_timing_array: *const CMSampleTimingInfo,
num_sample_size_entries: isize,
sample_size_array: *const usize,
sample_buffer_out: *mut CMSampleBufferRef,
) -> OSStatus;
}
#[link(name = "VideoToolbox", kind = "framework")]
extern "C" {
pub fn VTDecompressionSessionCreate(
allocator: CFAllocatorRef,
video_format_description: CMVideoFormatDescriptionRef,
video_decoder_specification: CFDictionaryRef,
destination_image_buffer_attributes: CFDictionaryRef,
output_callback: *const VTDecompressionOutputCallbackRecord,
decompression_session_out: *mut VTDecompressionSessionRef,
) -> OSStatus;
pub fn VTDecompressionSessionDecodeFrame(
session: VTDecompressionSessionRef,
sample_buffer: CMSampleBufferRef,
decode_flags: u32,
source_frame_refcon: *mut c_void,
info_flags_out: *mut u32,
) -> OSStatus;
pub fn VTDecompressionSessionWaitForAsynchronousFrames(
session: VTDecompressionSessionRef,
) -> OSStatus;
pub fn VTDecompressionSessionInvalidate(session: VTDecompressionSessionRef);
}
#[link(name = "CoreVideo", kind = "framework")]
extern "C" {
pub static kCVPixelBufferPixelFormatTypeKey: CFStringRef;
pub fn CVPixelBufferLockBaseAddress(pb: CVPixelBufferRef, flags: u64) -> i32;
pub fn CVPixelBufferUnlockBaseAddress(pb: CVPixelBufferRef, flags: u64) -> i32;
pub fn CVPixelBufferGetBaseAddress(pb: CVPixelBufferRef) -> *mut c_void;
pub fn CVPixelBufferGetBytesPerRow(pb: CVPixelBufferRef) -> usize;
pub fn CVPixelBufferGetWidth(pb: CVPixelBufferRef) -> usize;
pub fn CVPixelBufferGetHeight(pb: CVPixelBufferRef) -> usize;
}
}
+23
View File
@@ -0,0 +1,23 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum NdError {
#[error("connection failed: {0}")]
Connect(String),
#[error("protocol error: {0}")]
Protocol(String),
#[error("authentication failed: {0}")]
Auth(String),
#[error("session denied: {0}")]
Denied(String),
#[error("crypto error: {0}")]
Crypto(String),
#[error("websocket closed")]
Closed,
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type Result<T> = std::result::Result<T, NdError>;
File diff suppressed because it is too large. Load diff
+205
View File
@@ -0,0 +1,205 @@
//! LAN peer discovery over UDP broadcast.
//!
//! Mirrors RustDesk's `src/lan.rs` query/reply model: a viewer broadcasts a
//! small query to the LAN; every reachable host replies with its id, name and
//! the port it accepts a direct connection on. The viewer learns each host's IP
//! from the reply's source address — so two machines on the same subnet can find
//! each other with no rendezvous/signaling server involved (works even when the
//! internet is down).
//!
//! This module is the discovery half; establishing the session over the
//! discovered `ip:port` (a direct transport, bypassing the relay) is the wiring
//! step that builds on it. Packets are small JSON for debuggability.
//!
//! Developed by Akronlogic.
use std::net::{SocketAddr, UdpSocket};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
/// Default UDP port for NetDesk LAN discovery.
pub const LAN_PORT: u16 = 28118;
/// A host discovered on the LAN.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LanHost {
pub id: String,
pub name: String,
/// Where to reach the host directly: its LAN IP (from the reply source) and
/// the direct-connect port it advertised.
pub addr: SocketAddr,
}
#[derive(Serialize, Deserialize)]
struct Query<'a> {
t: &'a str, // "netdesk.lan.query"
}
#[derive(Serialize, Deserialize)]
struct Announce {
t: String, // "netdesk.lan.host"
id: String,
name: String,
port: u16, // direct-connect port
}
const QUERY_T: &str = "netdesk.lan.query";
const HOST_T: &str = "netdesk.lan.host";
/// The targets a production scan queries: the global broadcast address plus
/// loopback (so a host on this same machine is found even where broadcast to
/// self is unreliable). A caller may add subnet-directed broadcasts too.
pub fn broadcast_targets() -> Vec<SocketAddr> {
vec![
SocketAddr::from(([255, 255, 255, 255], LAN_PORT)),
SocketAddr::from(([127, 0, 0, 1], LAN_PORT)),
]
}
/// Host side: answer discovery queries until `stop` is set. Binds UDP
/// `0.0.0.0:bind_port` and replies to each query with this host's id/name and
/// `direct_port`. Best-effort: returns on a fatal bind error (discovery just
/// won't work); transient recv errors are ignored.
pub fn respond_to_queries(
bind_port: u16,
host_id: String,
name: String,
direct_port: u16,
stop: Arc<AtomicBool>,
) -> std::io::Result<()> {
let socket = UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], bind_port)))?;
socket.set_read_timeout(Some(Duration::from_millis(300)))?;
let reply = serde_json::to_vec(&Announce {
t: HOST_T.to_string(),
id: host_id,
name,
port: direct_port,
})
.unwrap_or_default();
let mut buf = [0u8; 2048];
while !stop.load(Ordering::Relaxed) {
match socket.recv_from(&mut buf) {
Ok((len, from)) => {
if is_query(&buf[..len]) {
let _ = socket.send_to(&reply, from);
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => continue,
Err(_) => continue,
}
}
Ok(())
}
fn is_query(buf: &[u8]) -> bool {
serde_json::from_slice::<Query>(buf)
.map(|q| q.t == QUERY_T)
.unwrap_or(false)
}
/// Viewer side: broadcast a discovery query to each `target` and collect host
/// replies for `timeout`. Production passes the LAN broadcast address
/// (`255.255.255.255:LAN_PORT`); tests pass a specific loopback address.
/// Deduplicates by host id (first reply wins).
pub fn scan(targets: &[SocketAddr], timeout: Duration) -> std::io::Result<Vec<LanHost>> {
let socket = UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], 0)))?;
socket.set_broadcast(true).ok(); // needed for 255.255.255.255; harmless otherwise
socket.set_read_timeout(Some(Duration::from_millis(200)))?;
let query = serde_json::to_vec(&Query { t: QUERY_T }).unwrap_or_default();
let send_all = || {
for t in targets {
let _ = socket.send_to(&query, t);
}
};
send_all();
let mut hosts: Vec<LanHost> = Vec::new();
let deadline = Instant::now() + timeout;
let mut next_resend = Instant::now() + Duration::from_millis(150);
let mut buf = [0u8; 2048];
while Instant::now() < deadline {
// Re-broadcast periodically: a single UDP query can be lost, and the
// responder may not have bound yet when we started.
if Instant::now() >= next_resend {
send_all();
next_resend = Instant::now() + Duration::from_millis(150);
}
match socket.recv_from(&mut buf) {
Ok((len, from)) => {
if let Ok(a) = serde_json::from_slice::<Announce>(&buf[..len]) {
if a.t == HOST_T && !hosts.iter().any(|h| h.id == a.id) {
hosts.push(LanHost {
id: a.id,
name: a.name,
addr: SocketAddr::new(from.ip(), a.port),
});
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => continue,
Err(_) => break,
}
}
Ok(hosts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn broadcast_targets_are_global_and_loopback() {
let t = broadcast_targets();
assert!(t.iter().any(|a| a.ip().to_string() == "255.255.255.255" && a.port() == LAN_PORT));
assert!(t.iter().any(|a| a.ip().to_string() == "127.0.0.1" && a.port() == LAN_PORT));
}
#[test]
fn scan_finds_a_responding_host() {
// A unique port so parallel tests don't collide.
let port = 29871u16;
let stop = Arc::new(AtomicBool::new(false));
let s2 = stop.clone();
let host = std::thread::spawn(move || {
let _ = respond_to_queries(
port,
"lan-host-123".to_string(),
"Test Host".to_string(),
41000,
s2,
);
});
// Give the responder a moment to bind.
std::thread::sleep(Duration::from_millis(150));
// Query loopback directly (broadcast is unreliable on loopback in CI).
let targets = [SocketAddr::from(([127, 0, 0, 1], port))];
let found = scan(&targets, Duration::from_millis(600)).unwrap();
stop.store(true, Ordering::Relaxed);
let _ = host.join();
let h = found
.iter()
.find(|h| h.id == "lan-host-123")
.expect("host not discovered on the LAN");
assert_eq!(h.name, "Test Host");
assert_eq!(h.addr.port(), 41000, "advertised direct-connect port");
assert!(h.addr.ip().is_loopback(), "host IP learned from reply source");
println!("LAN_DISCOVERY_OK found {} host(s): {:?}", found.len(), h);
}
#[test]
fn non_query_packets_are_ignored() {
assert!(is_query(br#"{"t":"netdesk.lan.query"}"#));
assert!(!is_query(br#"{"t":"something.else"}"#));
assert!(!is_query(b"garbage"));
}
}
+34
View File
@@ -0,0 +1,34 @@
//! NetDesk native core — the compiled Rust engine behind the cross-platform
//! client (Windows / macOS / Linux / iOS / Android). Developed by Akronlogic.
//!
//! This crate owns everything that must be fast and portable: signaling,
//! end-to-end crypto, the media transport, and (in later phases) H.264 decode
//! and input injection. The Flutter UI calls in over the C ABI in `ffi`.
pub mod brand;
pub mod cidr;
pub mod crypto;
pub mod decode;
pub mod error;
pub mod host;
pub mod lan;
pub mod netqos;
pub mod p2p;
pub mod proto;
pub mod recorder;
pub mod rtc;
pub mod webm;
pub mod signaling;
pub mod transport;
pub mod update;
pub use error::{NdError, Result};
pub use signaling::{connect, ConnectConfig, Session, SessionEvent};
pub use p2p::{upgrade_relay, P2pControl, P2pRelay, P2pRole};
pub use transport::{Lane, Transport, TransportMsg, WebRtcChannel, WsRelay};
pub use rtc::{advertise_max_message_size, answer_offer_web, build_api_web, create_web_channels, upgrade_offerer_web, WebChannels, WebDetached};
/// Core/build version, surfaced in the UI "about" box.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const BRAND: &str = "NetDesk";
pub const VENDOR: &str = "Akronlogic";
+223
View File
@@ -0,0 +1,223 @@
//! Network-quality estimators for the adaptive QoS controller — pure +
//! deterministic (feed samples, read values), so they unit-test without a live
//! link. RustDesk-style signals that replace the old "absolute RTT proxy":
//!
//! - [`RttCalc`] — a rolling-baseline RTT: the minimum RTT over the recent
//! window is the link's inherent latency, and the reported delay is how far
//! the latest sample sits ABOVE it. A stable high-latency link reads ~0
//! delay; only latency SPIKES (queue buildup) read high. (RustDesk's
//! `RttCalculator`.)
//! - [`LossCalc`] — packet-loss ratio from ping/pong hit/miss over a window.
//! - [`Jitter`] — the viewer measures its frame-arrival jitter (the mean
//! absolute deviation of inter-arrival intervals). Smooth delivery ~0;
//! bursty/stalled delivery (the decode pipeline falling behind) grows. This
//! is a congestion signal RTT alone misses, and it is reported to the host in
//! `net.pong` so `Qos::on_sample` gets a real end-to-end delay.
use std::collections::VecDeque;
/// Rolling-baseline RTT delay. `add` returns the delay of the new sample above
/// the window minimum (the baseline).
#[derive(Debug)]
pub struct RttCalc {
win: VecDeque<u32>,
cap: usize,
last: u32,
}
impl Default for RttCalc {
fn default() -> Self {
Self::new()
}
}
impl RttCalc {
/// ~30 samples (≈30 s at one probe/second) of history for the baseline.
pub fn new() -> Self {
Self {
win: VecDeque::new(),
cap: 30,
last: 0,
}
}
/// Record an RTT sample (ms); returns the delay above the rolling baseline.
pub fn add(&mut self, rtt_ms: u32) -> u32 {
self.win.push_back(rtt_ms);
while self.win.len() > self.cap {
self.win.pop_front();
}
let base = *self.win.iter().min().unwrap_or(&rtt_ms);
self.last = rtt_ms.saturating_sub(base);
self.last
}
/// The most recent delay-above-baseline.
pub fn delay(&self) -> u32 {
self.last
}
}
/// Packet-loss ratio over a rolling window of ping/pong outcomes.
#[derive(Debug)]
pub struct LossCalc {
win: VecDeque<bool>,
cap: usize,
}
impl Default for LossCalc {
fn default() -> Self {
Self::new()
}
}
impl LossCalc {
pub fn new() -> Self {
Self {
win: VecDeque::new(),
cap: 20,
}
}
/// Record one outcome (`true` = the pong was lost); returns the loss ratio.
pub fn record(&mut self, lost: bool) -> f32 {
self.win.push_back(lost);
while self.win.len() > self.cap {
self.win.pop_front();
}
let n = self.win.len().max(1);
self.win.iter().filter(|&&l| l).count() as f32 / n as f32
}
}
/// Viewer-side frame-arrival jitter: the mean absolute deviation of recent
/// inter-arrival intervals (ms). fps-independent (it measures spread, not the
/// absolute cadence), so lowering the frame rate does not read as congestion —
/// only bursty / stalled delivery does.
#[derive(Debug)]
pub struct Jitter {
intervals: VecDeque<u32>,
cap: usize,
last_ms: Option<u64>,
}
impl Default for Jitter {
fn default() -> Self {
Self::new()
}
}
impl Jitter {
pub fn new() -> Self {
Self {
intervals: VecDeque::new(),
cap: 30,
last_ms: None,
}
}
/// Record a frame arrival at monotonic `now_ms`; returns the current jitter.
pub fn frame(&mut self, now_ms: u64) -> u32 {
if let Some(prev) = self.last_ms {
// Clamp absurd gaps (a static screen sends nothing for a while) so a
// long idle doesn't poison the jitter once frames resume.
let dt = now_ms.saturating_sub(prev).min(2_000) as u32;
self.intervals.push_back(dt);
while self.intervals.len() > self.cap {
self.intervals.pop_front();
}
}
self.last_ms = Some(now_ms);
self.jitter()
}
/// Current jitter (mean absolute deviation of intervals, ms).
pub fn jitter(&self) -> u32 {
let n = self.intervals.len();
if n < 2 {
return 0;
}
let mean = self.intervals.iter().sum::<u32>() as f64 / n as f64;
let mad = self
.intervals
.iter()
.map(|&x| (x as f64 - mean).abs())
.sum::<f64>()
/ n as f64;
mad.round() as u32
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rtt_baseline_reports_delay_above_min() {
let mut r = RttCalc::new();
assert_eq!(r.add(200), 0, "first sample is its own baseline");
assert_eq!(r.add(200), 0);
assert_eq!(r.add(205), 5, "5 ms above the 200 ms floor");
assert_eq!(r.add(350), 150, "a spike reads 150 ms above baseline");
assert_eq!(r.add(200), 0, "back at the floor -> no delay");
assert_eq!(r.delay(), 0);
}
#[test]
fn rtt_baseline_rises_when_the_floor_rises() {
let mut r = RttCalc::new();
// Fill the window so old low samples roll out.
for _ in 0..30 {
r.add(100);
}
// A sustained worse link: once the 100s roll out, 300 becomes the floor.
for _ in 0..30 {
r.add(300);
}
assert_eq!(r.add(300), 0, "a sustained new floor reads ~0 delay again");
assert_eq!(r.add(450), 150, "spike above the new 300 ms floor");
}
#[test]
fn loss_ratio_tracks_misses() {
let mut l = LossCalc::new();
for _ in 0..10 {
l.record(false);
}
assert_eq!(l.record(false), 0.0, "all delivered -> 0 loss");
// 2 misses in a 13-sample window.
l.record(true);
let loss = l.record(true);
assert!((loss - 2.0 / 13.0).abs() < 1e-6, "2/13 lost, got {loss}");
}
#[test]
fn jitter_is_low_for_steady_delivery_and_high_for_bursts() {
let mut steady = Jitter::new();
let mut t = 0u64;
for _ in 0..20 {
t += 33; // a clean ~30 fps cadence
steady.frame(t);
}
assert!(steady.jitter() <= 1, "steady cadence -> ~0 jitter, got {}", steady.jitter());
let mut bursty = Jitter::new();
let mut t = 0u64;
for i in 0..20 {
t += if i % 2 == 0 { 5 } else { 70 }; // frames bunch then gap
bursty.frame(t);
}
assert!(bursty.jitter() > 20, "bursty delivery -> high jitter, got {}", bursty.jitter());
}
#[test]
fn jitter_ignores_a_long_idle_gap() {
let mut j = Jitter::new();
j.frame(0);
j.frame(33);
j.frame(66);
// Static screen: a 10 s gap is clamped, not counted as 10 s of jitter.
j.frame(10_066);
assert!(j.jitter() <= 2_000, "idle gap clamped");
}
}
+831
View File
@@ -0,0 +1,831 @@
//! Relay-first P2P multiplexer with seamless fallback.
//!
//! [`upgrade_relay`] wraps any [`Transport`] (the WSS relay) and returns another
//! `Transport` that the session loop ([`crate::signaling::connect_with`] /
//! [`crate::host::run_host_with`]) uses completely unchanged. All the P2P logic
//! runs in a background engine task that owns both the relay and, once
//! negotiated, the WebRTC DataChannel:
//!
//! * The relay is LIVE from the first frame and is NEVER torn down while P2P is
//! up (warm standby), so the first frame never waits on ICE and a P2P drop is
//! invisible to the session.
//! * The engine SNIFFS the session `sid` out of the handshake messages flowing
//! through it (so neither loop has to hand it over), then drives the
//! relay-brokered `rtc.*` handshake ([`crate::rtc::upgrade_offerer`] /
//! [`crate::rtc::answer_offer`]).
//! * UP edge: when the DataChannel opens AND the nominated ICE pair is DIRECT
//! (non-TURN — [`crate::rtc::selected_pair_is_direct`]), media + the
//! latency-critical input/keyframe control are routed over the channel; a
//! TURN-relayed or unknown pair keeps media on the relay.
//! * DOWN edge: if the peer connection fails/closes, media reroutes to the
//! still-open relay with no `Ended` and no frame loss.
//!
//! `rtc.*` and `session.*` ALWAYS ride the relay; only `CHAN_VIDEO` / file
//! binary and `i.mouse` / `i.key` / `keyframe` move to P2P when it is direct.
use std::sync::Arc;
use bytes::Bytes;
use serde_json::Value;
use tokio::sync::mpsc;
use webrtc::api::API;
use webrtc::data_channel::RTCDataChannel;
use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
use webrtc::peer_connection::RTCPeerConnection;
use webrtc::stats::StatsReportType;
use crate::error::{NdError, Result};
use crate::rtc;
use crate::rtc::{RtcIceServerJson, WebDetached};
use crate::transport::{install_reassembler, send_fragmented, Lane, Transport, TransportMsg};
/// A negotiated P2P connection: the native single reliable channel (16 KiB
/// app-fragmented) or the web 3-channel topology (media/ctl/move, detached,
/// whole frames) used to interop with the browser / Python host.
enum P2pConn {
Single(Arc<RTCDataChannel>),
Web(Arc<WebDetached>),
}
/// High-frequency, latency-critical control that rides the web `ctl` channel
/// (the web viewer's send override routes exactly these; other control stays on
/// the relay). Cheap prefix checks, no JSON parse on the hot path.
fn is_fast_path_text(t: &str) -> bool {
t.contains("\"t\":\"i.mouse\"")
|| t.contains("\"t\":\"i.key\"")
|| t.contains("\"t\":\"keyframe\"")
}
/// Whether the direct-P2P fast path is enabled for this process. DEFAULT-ON:
/// the native-peer capability gate keeps P2P to native<->native pairs and every
/// other pairing stays on the relay, so default-on cannot break the existing
/// web/Python peers. Opt OUT with `NETDESK_P2P=0` (also `false`/`off`/`no`).
pub fn p2p_enabled() -> bool {
!matches!(
std::env::var("NETDESK_P2P").as_deref(),
Ok("0") | Ok("false") | Ok("off") | Ok("no") | Ok("OFF") | Ok("FALSE") | Ok("NO")
)
}
/// Performance-based path health for the active DIRECT P2P pair.
///
/// The relay-first state machine already prefers a direct pair and falls back on
/// hard failure. This adds the other half of the hybrid: abandon a direct pair
/// that is measurably WORSE than a relay hop would be — not just one that
/// dropped. It is deliberately conservative (only a *pathologically* high,
/// *sustained* RTT trips it; a VPS relay hop is almost always below that), so it
/// never fights the normal "direct is faster" case. Feed it the nominated
/// candidate pair's RTT (ms) every ~[`HEALTH_INTERVAL`]; `on_rtt` returns true
/// once the path has been bad long enough to downgrade.
#[derive(Default)]
pub struct P2pHealth {
bad_streak: u32,
}
/// How often the engine samples the active pair's RTT.
pub const HEALTH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
/// A direct-pair RTT at/above this (ms) is "bad" — far worse than a relay hop.
pub const P2P_RTT_BAD_MS: u32 = 500;
/// Consecutive bad samples before downgrading (~10 s at [`HEALTH_INTERVAL`]).
pub const P2P_RTT_CONFIRM: u32 = 5;
impl P2pHealth {
/// Record one RTT sample (ms). `0` means "unknown" (not yet measured) and is
/// ignored. Returns true when a downgrade to the relay is warranted.
pub fn on_rtt(&mut self, rtt_ms: u32) -> bool {
if rtt_ms == 0 {
return false;
}
if rtt_ms >= P2P_RTT_BAD_MS {
self.bad_streak += 1;
} else {
self.bad_streak = 0;
}
self.bad_streak >= P2P_RTT_CONFIRM
}
}
/// The nominated ICE candidate-pair RTT in ms, or 0 if unknown/not yet measured.
async fn p2p_pair_rtt_ms(pc: &Arc<RTCPeerConnection>) -> u32 {
let report = pc.get_stats().await;
for v in report.reports.values() {
if let StatsReportType::CandidatePair(cp) = v {
if cp.nominated && cp.current_round_trip_time > 0.0 {
return (cp.current_round_trip_time * 1000.0).round() as u32;
}
}
}
0
}
/// Which side of the upgrade handshake this peer drives.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum P2pRole {
/// Viewer — sends `rtc.config.req` + the offer.
Offerer,
/// Host — answers an inbound `rtc.offer`.
Answerer,
}
/// SCTP send-queue depth (bytes) past which a fresh MEDIA (video) frame is
/// DROPPED rather than queued — fresh beats late. Only the drop-tolerant Media
/// lane drops; Ctl (file bytes / control) and Move are always sent reliably.
/// Matches the web host's 256 KiB `_send_bin` threshold.
const P2P_MEDIA_CAP: usize = 262144;
/// A handle to force the session back onto the relay (the plan's "force-relay
/// fast path", and the test hook for the DOWN edge). Cloneable; outlives the
/// transport move into the session loop.
#[derive(Clone)]
pub struct P2pControl {
down: mpsc::UnboundedSender<()>,
}
impl P2pControl {
/// Drop any active P2P path and route all media back over the warm relay.
pub fn force_relay(&self) {
let _ = self.down.send(());
}
}
/// The app-facing transport the session loop uses unchanged — just the mailbox
/// into the engine task.
pub struct P2pRelay {
out: mpsc::UnboundedSender<(TransportMsg, Lane)>,
inn: mpsc::UnboundedReceiver<Result<TransportMsg>>,
down: mpsc::UnboundedSender<()>,
_engine: tokio::task::JoinHandle<()>,
}
impl P2pRelay {
/// A control handle to force media back onto the relay.
pub fn control(&self) -> P2pControl {
P2pControl {
down: self.down.clone(),
}
}
}
#[async_trait::async_trait]
impl Transport for P2pRelay {
async fn send_binary(&mut self, data: Vec<u8>) -> Result<()> {
// No lane hint -> treat as reliable Ctl (never dropped). Callers that
// know it is video use send_binary_lane(.., Media).
self.out
.send((TransportMsg::Binary(data), Lane::Ctl))
.map_err(|_| NdError::Connect("p2p engine stopped".into()))
}
async fn send_binary_lane(&mut self, data: Vec<u8>, lane: Lane) -> Result<()> {
self.out
.send((TransportMsg::Binary(data), lane))
.map_err(|_| NdError::Connect("p2p engine stopped".into()))
}
async fn send_text(&mut self, text: String) -> Result<()> {
self.out
.send((TransportMsg::Text(text), Lane::Ctl))
.map_err(|_| NdError::Connect("p2p engine stopped".into()))
}
async fn recv(&mut self) -> Option<Result<TransportMsg>> {
self.inn.recv().await
}
}
/// Wrap `relay` with transparent P2P upgrade + fallback. `api` builds the peer
/// connection(s). The returned transport is a drop-in for the relay.
pub fn upgrade_relay(relay: Box<dyn Transport>, role: P2pRole, api: Arc<API>) -> P2pRelay {
let (app_out_tx, app_out_rx) = mpsc::unbounded_channel::<(TransportMsg, Lane)>();
let (app_in_tx, app_in_rx) = mpsc::unbounded_channel::<Result<TransportMsg>>();
let (down_tx, down_rx) = mpsc::unbounded_channel::<()>();
let engine = tokio::spawn(engine(
relay,
role,
api,
app_out_rx,
app_in_tx,
down_tx.clone(),
down_rx,
));
P2pRelay {
out: app_out_tx,
inn: app_in_rx,
down: down_tx,
_engine: engine,
}
}
/// Pull `sid` out of a control-text message, if present and non-empty.
fn sniff_sid_text(t: &str) -> Option<String> {
serde_json::from_str::<Value>(t)
.ok()
.and_then(|v| v.get("sid").and_then(|x| x.as_str()).map(|s| s.to_string()))
.filter(|s| !s.is_empty())
}
/// Whether this control text rides the P2P channel when it is up. Everything
/// does EXCEPT `session.*` (session lifecycle) and `rtc.*` (upgrade signaling),
/// which stay on the relay. Keeping ALL app traffic — input, keyframe requests
/// AND file.* control — on the one ordered channel alongside the binary data is
/// essential: splitting data (P2P) from its `file.done` (relay) lets the text
/// race ahead of the slower-fragmented data and truncate the download. Cheap
/// substring checks avoid a JSON parse on the hot path (serde emits `t` first).
fn route_text_p2p(t: &str) -> bool {
!(t.contains("\"t\":\"session.") || t.contains("\"t\":\"rtc."))
}
type DoneTx = mpsc::UnboundedSender<(Arc<RTCPeerConnection>, P2pConn)>;
fn spawn_offerer(
api: Arc<API>,
sid: String,
rtc_out: mpsc::UnboundedSender<String>,
mut rtc_in: mpsc::UnboundedReceiver<Value>,
done: DoneTx,
) {
tokio::spawn(async move {
let send = {
let tx = rtc_out.clone();
move |s: String| {
let _ = tx.send(s);
}
};
match rtc::upgrade_offerer(&api, &sid, None, "netdesk", &send, &mut rtc_in).await {
Ok((pc, dc)) => {
let _ = done.send((pc, P2pConn::Single(dc)));
}
Err(e) => tracing::debug!("P2P offerer upgrade abandoned: {e}"),
}
});
}
/// WEB-topology offerer (interop with the browser / Python host). Builds its own
/// detach-enabled API, negotiates media/ctl/move, and reports a `P2pConn::Web`.
fn spawn_offerer_web(
sid: String,
rtc_out: mpsc::UnboundedSender<String>,
mut rtc_in: mpsc::UnboundedReceiver<Value>,
done: DoneTx,
) {
tokio::spawn(async move {
let api = match rtc::build_api_web() {
Ok(a) => a,
Err(e) => {
tracing::debug!("build_api_web: {e}");
return;
}
};
let send = {
let tx = rtc_out.clone();
move |s: String| {
let _ = tx.send(s);
}
};
match rtc::upgrade_offerer_web(&api, &sid, None, &send, &mut rtc_in).await {
Ok((pc, web)) => {
let _ = done.send((pc, P2pConn::Web(Arc::new(web))));
}
Err(e) => tracing::debug!("P2P web offerer abandoned: {e}"),
}
});
}
fn spawn_answerer(
api: Arc<API>,
sid: String,
offer_sdp: String,
ice: Vec<RtcIceServerJson>,
rtc_out: mpsc::UnboundedSender<String>,
done: DoneTx,
) {
tokio::spawn(async move {
let send = {
let tx = rtc_out.clone();
move |s: String| {
let _ = tx.send(s);
}
};
match rtc::answer_offer(&api, &sid, &offer_sdp, ice, &send).await {
Ok((pc, dc)) => {
let _ = done.send((pc, P2pConn::Single(dc)));
}
Err(e) => tracing::debug!("P2P answerer abandoned: {e}"),
}
});
}
/// WEB-topology answerer (a browser / web viewer connects to this native host).
/// Builds its own detach-enabled API and reports a `P2pConn::Web`.
fn spawn_answerer_web(
sid: String,
offer_sdp: String,
ice: Vec<RtcIceServerJson>,
rtc_out: mpsc::UnboundedSender<String>,
done: DoneTx,
) {
tokio::spawn(async move {
let api = match rtc::build_api_web() {
Ok(a) => a,
Err(e) => {
tracing::debug!("build_api_web: {e}");
return;
}
};
let send = {
let tx = rtc_out.clone();
move |s: String| {
let _ = tx.send(s);
}
};
match rtc::answer_offer_web(&api, &sid, &offer_sdp, ice, &send).await {
Ok((pc, web)) => {
let _ = done.send((pc, P2pConn::Web(Arc::new(web))));
}
Err(e) => tracing::debug!("P2P web answerer abandoned: {e}"),
}
});
}
/// Start one offerer upgrade attempt (the initial one, or a re-upgrade after a
/// drop). Creates a fresh rtc_in channel, spawns the Single or Web offerer, and
/// returns the sender the relay loop forwards inbound rtc.* to.
fn start_offerer_attempt(
web: bool,
api: Arc<API>,
sid: String,
rtc_out: mpsc::UnboundedSender<String>,
done: DoneTx,
) -> mpsc::UnboundedSender<Value> {
let (tx, rx) = mpsc::unbounded_channel::<Value>();
if web {
spawn_offerer_web(sid, rtc_out, rx, done);
} else {
spawn_offerer(api, sid, rtc_out, rx, done);
}
tx
}
#[allow(clippy::too_many_arguments)]
async fn engine(
mut relay: Box<dyn Transport>,
role: P2pRole,
api: Arc<API>,
mut app_out_rx: mpsc::UnboundedReceiver<(TransportMsg, Lane)>,
app_in_tx: mpsc::UnboundedSender<Result<TransportMsg>>,
down_tx: mpsc::UnboundedSender<()>,
mut down_rx: mpsc::UnboundedReceiver<()>,
) {
let mut sid: Option<String> = None;
let mut media_on_p2p = false;
let mut p2p_send: Option<P2pConn> = None;
let mut _pc: Option<Arc<RTCPeerConnection>> = None;
let mut next_frame_id: u32 = 0;
let mut offerer_started = false;
let mut answerer_started = false;
// Performance health of the active direct pair, and a latch: once a direct
// pair is abandoned for being too slow, stay on the relay for the rest of
// the session (don't re-upgrade into the same bad path and flap).
let mut health = P2pHealth::default();
let mut health_down = false;
// Inbound P2P frames (reassembled) land here; held open for the engine's
// lifetime so re-use across the session is clean. Fed by install_reassembler
// once a channel is established.
let (p2p_in_tx, mut p2p_in_rx) = mpsc::unbounded_channel::<TransportMsg>();
// rtc.* the upgrade task wants put on the relay.
let (rtc_out_tx, mut rtc_out_rx) = mpsc::unbounded_channel::<String>();
// Inbound rtc.* forwarded to the CURRENT offerer upgrade attempt. Re-created
// per attempt so a re-upgrade after a drop gets a fresh channel.
let mut rtc_in_tx: Option<mpsc::UnboundedSender<Value>> = None;
// Negotiated (pc, connection).
let (done_tx, mut done_rx) =
mpsc::unbounded_channel::<(Arc<RTCPeerConnection>, P2pConn)>();
// Re-upgrade (hysteresis): after a P2P drop, retry the upgrade once after a
// backoff so a transient drop recovers to P2P instead of staying on the
// relay. Default 15s; shorter in tests via NETDESK_P2P_REUP_MS.
let reup_ms: u64 = std::env::var("NETDESK_P2P_REUP_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(15000);
let (reup_tx, mut reup_rx) = mpsc::unbounded_channel::<()>();
// Offerer topology once decided: Some(false)=native single, Some(true)=web,
// None=relay-only. Remembered so a re-upgrade uses the same topology.
let mut offerer_mode: Option<bool> = None;
// Periodic RTT health probe of the active direct pair.
let mut health_tick = tokio::time::interval(HEALTH_INTERVAL);
health_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
enum Ev {
AppOut(Option<(TransportMsg, Lane)>),
RelayIn(Option<Result<TransportMsg>>),
P2pIn(Option<TransportMsg>),
RtcOut(Option<String>),
Done(Option<(Arc<RTCPeerConnection>, P2pConn)>),
ReUpgrade,
Down,
Health,
}
loop {
let ev = tokio::select! {
m = app_out_rx.recv() => Ev::AppOut(m),
m = relay.recv() => Ev::RelayIn(m),
m = p2p_in_rx.recv() => Ev::P2pIn(m),
s = rtc_out_rx.recv() => Ev::RtcOut(s),
d = done_rx.recv() => Ev::Done(d),
_ = reup_rx.recv() => Ev::ReUpgrade,
_ = down_rx.recv() => Ev::Down,
_ = health_tick.tick() => Ev::Health,
};
match ev {
// ---- outbound from the session loop ----
Ev::AppOut(None) => break, // the session dropped the transport
Ev::AppOut(Some((msg, lane))) => {
if sid.is_none() {
if let TransportMsg::Text(t) = &msg {
if let Some(s) = sniff_sid_text(t) {
sid = Some(s);
}
}
}
match msg {
TransportMsg::Binary(b) => match (media_on_p2p, p2p_send.as_ref()) {
// native single channel: 16 KiB app-fragments; Media is
// drop-tolerant past the queue cap, Ctl/Move reliable.
(true, Some(P2pConn::Single(dc))) => {
let dc = dc.clone();
if lane == Lane::Media && dc.buffered_amount().await > P2P_MEDIA_CAP {
// drop (fresh beats late)
} else {
let fid = next_frame_id;
next_frame_id = next_frame_id.wrapping_add(1);
if send_fragmented(&dc, fid, &b).await.is_err() {
let _ = relay.send_binary_lane(b, lane).await;
let _ = down_tx.send(());
}
}
}
// web topology: video (Media) rides the media channel whole;
// everything else (e.g. file upload) stays on the relay, like
// the web viewer. Media is drop-tolerant past the cap.
(true, Some(P2pConn::Web(web))) => {
if lane == Lane::Media {
if web.media.buffered_amount() <= P2P_MEDIA_CAP
&& web
.media
.write_data_channel(&Bytes::from(b.clone()), false)
.await
.is_err()
{
let _ = relay.send_binary_lane(b, lane).await;
let _ = down_tx.send(());
}
} else {
let _ = relay.send_binary_lane(b, lane).await;
}
}
_ => {
let _ = relay.send_binary_lane(b, lane).await;
}
},
TransportMsg::Text(t) => match (media_on_p2p, p2p_send.as_ref()) {
(true, Some(P2pConn::Single(dc))) if route_text_p2p(&t) => {
let dc = dc.clone();
if dc.send_text(t.clone()).await.is_err() {
let _ = relay.send_text(t).await;
let _ = down_tx.send(());
}
}
// web: only input / keyframe rides ctl; other control stays
// on the relay (the web viewer's send policy).
(true, Some(P2pConn::Web(web))) if is_fast_path_text(&t) => {
if web
.ctl
.write_data_channel(&Bytes::from(t.clone().into_bytes()), true)
.await
.is_err()
{
let _ = relay.send_text(t).await;
let _ = down_tx.send(());
}
}
_ => {
let _ = relay.send_text(t).await;
}
},
}
}
// ---- inbound from the relay ----
Ev::RelayIn(None) => break, // relay closed -> session ends (app recv None)
Ev::RelayIn(Some(Err(e))) => {
let _ = app_in_tx.send(Err(e));
break;
}
Ev::RelayIn(Some(Ok(m))) => {
let mut forward = true;
if let TransportMsg::Text(t) = &m {
if let Ok(v) = serde_json::from_str::<Value>(t) {
let tt = v.get("t").and_then(|x| x.as_str()).unwrap_or("").to_string();
if sid.is_none() {
if let Some(s) = v.get("sid").and_then(|x| x.as_str()) {
if !s.is_empty() {
sid = Some(s.to_string());
}
}
}
if tt.starts_with("rtc.") {
forward = false; // rtc.* is ours, never the app's
match role {
P2pRole::Offerer => {
if let Some(tx) = &rtc_in_tx {
let _ = tx.send(v);
}
}
P2pRole::Answerer => {
if tt == "rtc.offer" && !answerer_started {
answerer_started = true;
let sid_s = sid.clone().unwrap_or_default();
let is_native =
v.get("native").and_then(|x| x.as_bool()) == Some(true);
let sdp = v
.get("sdp")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let ice: Vec<RtcIceServerJson> = v
.get("ice")
.and_then(|x| serde_json::from_value(x.clone()).ok())
.unwrap_or_default();
if is_native {
// native single-channel offerer.
spawn_answerer(
api.clone(),
sid_s,
sdp,
ice,
rtc_out_tx.clone(),
done_tx.clone(),
);
} else if std::env::var("NETDESK_P2P_WEB").is_ok() {
// web/3-channel offerer (a browser) — answer in
// web topology when opted in.
spawn_answerer_web(
sid_s,
sdp,
ice,
rtc_out_tx.clone(),
done_tx.clone(),
);
} else {
// not opted into web interop: decline so the
// pairing stays on the relay.
let _ = rtc_out_tx.send(
format!(r#"{{"t":"rtc.unavailable","sid":"{sid_s}"}}"#),
);
}
}
}
}
} else if role == P2pRole::Offerer
&& !offerer_started
&& tt == "session.accept"
{
// A NATIVE host (info.p2p == "native") uses the native
// single-channel upgrade. A non-native host (browser /
// Python) uses the WEB 3-channel topology, but only when
// NETDESK_P2P_WEB is opted in — so the default stays the
// safe native-only behavior until the live cross-matrix
// is confirmed.
let native_host = v
.get("info")
.and_then(|i| i.get("p2p"))
.and_then(|p| p.as_str())
== Some("native");
let web_ok = std::env::var("NETDESK_P2P_WEB").is_ok();
if native_host {
offerer_started = true;
offerer_mode = Some(false);
rtc_in_tx = Some(start_offerer_attempt(
false,
api.clone(),
sid.clone().unwrap_or_default(),
rtc_out_tx.clone(),
done_tx.clone(),
));
} else if web_ok {
offerer_started = true;
offerer_mode = Some(true);
rtc_in_tx = Some(start_offerer_attempt(
true,
api.clone(),
sid.clone().unwrap_or_default(),
rtc_out_tx.clone(),
done_tx.clone(),
));
}
}
}
}
if forward {
let _ = app_in_tx.send(Ok(m));
}
}
// ---- inbound from the P2P channel ----
Ev::P2pIn(Some(m)) => {
let _ = app_in_tx.send(Ok(m));
}
Ev::P2pIn(None) => {} // we hold a sender; this does not fire in practice
// ---- rtc.* the upgrade task wants on the relay ----
Ev::RtcOut(Some(s)) => {
let _ = relay.send_text(s).await;
}
Ev::RtcOut(None) => {}
// ---- UP edge: a channel was negotiated ----
Ev::Done(Some((new_pc, conn))) => {
let direct = rtc::selected_pair_is_direct(&new_pc).await;
match &conn {
P2pConn::Single(dc) => {
// native: 16 KiB app-fragments reassembled by install_reassembler.
install_reassembler(dc, p2p_in_tx.clone());
}
P2pConn::Web(web) => {
// web: whole frames — spawn a detached read-loop per inbound
// channel (media = video, ctl = control). Each read is one
// complete message (is_string -> Text, else Binary).
for raw in [web.media.clone(), web.ctl.clone()] {
let tx = p2p_in_tx.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 262144];
loop {
match raw.read_data_channel(&mut buf).await {
Ok((0, _)) => break,
Ok((n, is_string)) => {
let msg = if is_string {
match String::from_utf8(buf[..n].to_vec()) {
Ok(s) => TransportMsg::Text(s),
Err(_) => continue,
}
} else {
TransportMsg::Binary(buf[..n].to_vec())
};
let _ = tx.send(msg);
}
Err(_) => break,
}
}
});
}
}
}
let dtx = down_tx.clone();
new_pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
let dtx = dtx.clone();
Box::pin(async move {
if matches!(
s,
RTCPeerConnectionState::Failed
| RTCPeerConnectionState::Disconnected
| RTCPeerConnectionState::Closed
) {
let _ = dtx.send(());
}
})
}));
p2p_send = Some(conn);
_pc = Some(new_pc);
// Only move media off the relay for a DIRECT pair; a TURN-relayed
// hop is no better than our own relay.
media_on_p2p = direct;
tracing::info!(direct, "P2P DataChannel open");
if role == P2pRole::Offerer {
let _ = app_in_tx.send(Ok(TransportMsg::Text(format!(
r#"{{"t":"p2p.state","active":{media_on_p2p},"direct":{direct}}}"#
))));
}
}
Ev::Done(None) => {}
// ---- periodic health probe: abandon a pathologically slow direct
// pair for the relay (the "pick the faster path" half of hybrid). ----
Ev::Health => {
if media_on_p2p && !health_down {
if let Some(pc) = &_pc {
let rtt = p2p_pair_rtt_ms(pc).await;
if health.on_rtt(rtt) {
tracing::warn!(
rtt_ms = rtt,
"P2P direct pair too slow — downgrading to the relay for the session"
);
health_down = true;
let _ = down_tx.send(());
}
}
}
}
// ---- DOWN edge: pc failed/closed -> fall back to the warm relay ----
Ev::Down => {
let was_up = media_on_p2p || p2p_send.is_some();
if was_up {
tracing::info!("P2P dropped — continuing over the relay");
}
media_on_p2p = false;
p2p_send = None;
_pc = None;
health = P2pHealth::default(); // reset the RTT streak for the next pair
if was_up && role == P2pRole::Offerer {
let _ = app_in_tx.send(Ok(TransportMsg::Text(
r#"{"t":"p2p.state","active":false,"direct":false}"#.to_string(),
)));
}
// Re-upgrade hysteresis: allow a fresh attempt. The answerer
// just re-answers the viewer's next rtc.offer; the offerer
// schedules one retry after a backoff.
if was_up {
answerer_started = false;
offerer_started = false;
rtc_in_tx = None;
// Suppress re-upgrade after a health downgrade: the direct
// path proved too slow, so stay on the relay (no flapping).
if role == P2pRole::Offerer && offerer_mode.is_some() && !health_down {
let reup_tx = reup_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(reup_ms)).await;
let _ = reup_tx.send(());
});
}
}
}
// ---- re-upgrade timer fired: retry the offerer upgrade once ----
Ev::ReUpgrade => {
if role == P2pRole::Offerer
&& !offerer_started
&& p2p_send.is_none()
&& sid.is_some()
{
if let Some(web) = offerer_mode {
offerer_started = true;
rtc_in_tx = Some(start_offerer_attempt(
web,
api.clone(),
sid.clone().unwrap_or_default(),
rtc_out_tx.clone(),
done_tx.clone(),
));
tracing::info!("P2P re-upgrade attempt");
}
}
}
}
}
}
#[cfg(test)]
mod health_tests {
use super::{P2pHealth, P2P_RTT_CONFIRM};
#[test]
fn good_rtt_never_downgrades() {
let mut h = P2pHealth::default();
for _ in 0..50 {
assert!(!h.on_rtt(30), "a healthy direct pair must stay on P2P");
}
}
#[test]
fn unknown_rtt_is_ignored() {
let mut h = P2pHealth::default();
for _ in 0..50 {
assert!(!h.on_rtt(0), "0 = not-yet-measured, never a downgrade");
}
}
#[test]
fn sustained_bad_rtt_downgrades_after_confirm() {
let mut h = P2pHealth::default();
// Must take the full confirm streak — not a single spike.
for i in 1..P2P_RTT_CONFIRM {
assert!(!h.on_rtt(800), "should not trip on sample {i} (< confirm)");
}
assert!(h.on_rtt(800), "should downgrade once the streak reaches confirm");
}
#[test]
fn a_good_sample_resets_the_streak() {
let mut h = P2pHealth::default();
for _ in 0..(P2P_RTT_CONFIRM - 1) {
assert!(!h.on_rtt(900));
}
assert!(!h.on_rtt(40), "a healthy sample clears the bad streak");
// Now it must take another full streak to trip.
for i in 1..P2P_RTT_CONFIRM {
assert!(!h.on_rtt(900), "streak restarted; sample {i} must not trip");
}
assert!(h.on_rtt(900));
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Wire protocol for the NetDesk signaling server (`/ws`). Mirrors the messages
//! the web viewer exchanges, so the native client is a drop-in peer:
//! viewer -> session.begin{id}
//! host -> session.challenge{sid,salt,nonce,iters,e2e,pub,totp}
//! viewer -> session.auth{proof,pub,totp}
//! host -> session.accept{sid,info{host_name,e2e,w,h,...}} (then binary frames)
//! host -> session.deny{reason}
//! Binary frames (after optional AES-GCM): [0]=channel, [1]=flags, [2..]=codec
//! bitstream. For CHAN_VIDEO, `flags` packs bit0=keyframe and bits1-3=codec id
//! (see [`VideoCodec`] / [`video_flags`]): H.264 Annex-B (0) or VP9 (1).
use serde::{Deserialize, Serialize};
pub const CHAN_VIDEO: u8 = 0x01;
pub const CHAN_AUDIO: u8 = 0xa1;
/// Viewer->host microphone for a two-way voice call: `[CHAN_VOICE][opus]`,
/// E2E-sealed like the other channels. The host decodes it and plays it on its
/// default render endpoint.
pub const CHAN_VOICE: u8 = 0xa2;
pub const CHAN_FILE: u8 = 0xf1;
pub const CHAN_FILE_END: u8 = 0xf2;
/// Video codec carried in the high bits of a CHAN_VIDEO frame's flags byte.
///
/// The host tags every frame; the viewer builds the matching decoder and can
/// switch mid-session at any keyframe (the adaptive path moves hardware H.264
/// <-> software VP9 by link quality — VP9 is far better per-bitrate on slow
/// links, H.264 is near-free on fast ones). Values are forward-compatible:
/// bits1-3 leave room for VP8/AV1 later.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[repr(u8)]
pub enum VideoCodec {
H264 = 0,
Vp9 = 1,
}
impl VideoCodec {
/// Decode the codec id from a CHAN_VIDEO flags byte (unknown -> H264).
#[inline]
pub fn from_bits(flags: u8) -> Self {
match (flags >> 1) & 0x7 {
1 => VideoCodec::Vp9,
_ => VideoCodec::H264,
}
}
#[inline]
pub fn as_u8(self) -> u8 {
self as u8
}
}
/// Pack a CHAN_VIDEO flags byte: bit0 = keyframe, bits1-3 = codec id.
#[inline]
pub fn video_flags(keyframe: bool, codec: VideoCodec) -> u8 {
(keyframe as u8) | ((codec as u8) << 1)
}
/// Outbound: begin a session against a host id.
#[derive(Serialize)]
pub struct SessionBegin<'a> {
pub t: &'a str, // "session.begin"
pub id: &'a str,
pub direct: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub takeover: Option<bool>,
/// Viewer can decode VP9 — the host only sends VP9 frames when this is set
/// (a non-VP9 viewer, e.g. a non-Windows native build or the web client,
/// stays on H.264). Omitted when false so older hosts ignore it.
#[serde(skip_serializing_if = "Option::is_none")]
pub vp9: Option<bool>,
}
/// Inbound: the host's auth challenge (relayed through the server).
#[derive(Deserialize, Debug, Clone)]
pub struct Challenge {
pub sid: String,
pub salt: String, // base64
pub nonce: String, // opaque string, concatenated before hashing
pub iters: u32,
#[serde(default)]
pub e2e: bool,
#[serde(default, rename = "pub")]
pub pub_: Option<String>, // host ECDH P-256 public key, base64 raw (65 bytes)
#[serde(default)]
pub totp: bool,
}
/// Outbound: proof of the password + our ECDH public key.
#[derive(Serialize)]
pub struct SessionAuth<'a> {
pub t: &'a str, // "session.auth"
pub proof: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "pub")]
pub pub_: Option<String>,
/// Channel-bound proof (MITM hardening): `hex(SHA256(nonce||verifier||host_pub))`.
/// Sent alongside `proof` whenever E2E is active, so the host can detect a
/// relay that swapped its ephemeral key. Omitted on non-E2E sessions; older
/// hosts ignore the extra field.
#[serde(skip_serializing_if = "Option::is_none")]
pub proof_bind: Option<String>,
pub totp: String,
pub sid: String,
/// Viewer can decode VP9. Carried in `session.auth` (not `session.begin`,
/// whose custom fields the signaling server strips — it does relay auth,
/// since the host reads our `pub` here for E2E). The host only sends VP9
/// when this is set. Omitted when false so older hosts ignore it.
#[serde(skip_serializing_if = "Option::is_none")]
pub vp9: Option<bool>,
}
/// Inbound: accepted. `info` carries host name, dims, e2e flag, etc.
#[derive(Deserialize, Debug, Clone, Default)]
pub struct AcceptInfo {
#[serde(default)]
pub host_name: Option<String>,
#[serde(default)]
pub e2e: bool,
#[serde(default)]
pub w: Option<u32>,
#[serde(default)]
pub h: Option<u32>,
}
/// A keyboard/pointer input event sent viewer -> host.
#[derive(Serialize)]
pub struct MouseInput<'a> {
pub t: &'a str, // "i.mouse"
pub x: i32,
pub y: i32,
pub b: u32,
pub w: f32,
pub hw: f32,
pub sid: String,
}
#[derive(Serialize)]
pub struct KeyInput<'a> {
pub t: &'a str, // "i.key"
// The host (netdesk_host.py) and the web viewer both use the short wire
// names `c` (DOM code) and `d` (down). The native viewer MUST match them or
// the host reads an empty code with down=false and injects nothing — i.e.
// the keyboard is silently dead ("connects but it's just a view").
#[serde(rename = "c")]
pub code: String,
#[serde(rename = "d")]
pub down: bool,
pub sid: String,
}
/// Split an H.264 Annex-B buffer into NAL units (strips 00 00 01 / 00 00 00 01 start codes).
pub fn split_nals(buf: &[u8]) -> Vec<&[u8]> {
let mut out = Vec::new();
let mut i = 0usize;
let n = buf.len();
let mut start: Option<usize> = None;
while i + 3 <= n {
let sc3 = buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1;
let sc4 = i + 4 <= n && buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 0 && buf[i + 3] == 1;
if sc3 || sc4 {
if let Some(s) = start {
out.push(&buf[s..i]);
}
i += if sc4 { 4 } else { 3 };
start = Some(i);
} else {
i += 1;
}
}
if let Some(s) = start {
if s < n {
out.push(&buf[s..n]);
}
}
out
}
/// NAL unit type (lower 5 bits of the first byte). 7=SPS, 8=PPS, 5=IDR, 1=non-IDR.
pub fn nal_type(nal: &[u8]) -> u8 {
if nal.is_empty() {
0
} else {
nal[0] & 0x1f
}
}
+182
View File
@@ -0,0 +1,182 @@
//! Session recording — repackage the received H.264 Annex-B access units into a
//! playable `.mp4` with **no re-encode** (just remuxing). The viewer tees the
//! same access units it decodes for display into this recorder; the host never
//! has to do anything. Variable-framerate is preserved by timing each sample
//! from its arrival timestamp (the host only sends a frame when the screen
//! changes, so fixed-rate muxing would speed up idle stretches).
//!
//! Developed by Akronlogic.
use crate::error::{NdError, Result};
use crate::proto::{nal_type, split_nals};
use std::fs::File;
use std::io::{BufReader, BufWriter};
use std::path::{Path, PathBuf};
fn other(msg: impl std::fmt::Display) -> NdError {
NdError::Other(anyhow::anyhow!("recorder: {msg}"))
}
/// Muxes H.264 access units into an MP4 file on disk.
pub struct Mp4Recorder {
path: PathBuf,
width: u16,
height: u16,
writer: Option<mp4::Mp4Writer<BufWriter<File>>>,
sps: Option<Vec<u8>>,
pps: Option<Vec<u8>>,
start_ms: Option<u64>,
/// One buffered sample so its duration can be set from the NEXT sample's
/// timestamp (MP4 samples carry how long they are shown).
pending: Option<(Vec<u8>, bool, u64)>, // (avcc bytes, is_sync, rel_ms)
samples: u64,
}
const TIMESCALE: u32 = 1000; // milliseconds
impl Mp4Recorder {
/// Create a recorder that will write to `path`. `width`/`height` are the
/// encoded frame size (from `session.info`); the file is only created once
/// the first keyframe (SPS+PPS) arrives.
pub fn new(path: impl AsRef<Path>, width: u16, height: u16) -> Self {
Self {
path: path.as_ref().to_path_buf(),
width: width.max(2),
height: height.max(2),
writer: None,
sps: None,
pps: None,
start_ms: None,
pending: None,
samples: 0,
}
}
/// Feed one access unit (Annex-B). `ts_ms` is a monotonically increasing
/// arrival time in milliseconds. SPS/PPS are harvested automatically and the
/// file starts on the first keyframe.
pub fn push(&mut self, au: &[u8], keyframe: bool, ts_ms: u64) -> Result<()> {
let nals = split_nals(au);
// Harvest parameter sets (needed for the avcC box).
for nal in &nals {
match nal_type(nal) {
7 if self.sps.is_none() => self.sps = Some(nal.to_vec()),
8 if self.pps.is_none() => self.pps = Some(nal.to_vec()),
_ => {}
}
}
// Lazily open the file once we can describe the track.
if self.writer.is_none() {
let (sps, pps) = match (self.sps.clone(), self.pps.clone()) {
(Some(s), Some(p)) => (s, p),
_ => return Ok(()), // wait for the first keyframe's SPS/PPS
};
let file = BufWriter::new(File::create(&self.path).map_err(other)?);
let config = mp4::Mp4Config {
major_brand: mp4::FourCC::from(*b"isom"),
minor_version: 512,
compatible_brands: vec![
mp4::FourCC::from(*b"isom"),
mp4::FourCC::from(*b"iso2"),
mp4::FourCC::from(*b"avc1"),
mp4::FourCC::from(*b"mp41"),
],
timescale: TIMESCALE,
};
let mut w = mp4::Mp4Writer::write_start(file, &config).map_err(other)?;
let track = mp4::TrackConfig {
track_type: mp4::TrackType::Video,
timescale: TIMESCALE,
language: "und".to_string(),
media_conf: mp4::MediaConfig::AvcConfig(mp4::AvcConfig {
width: self.width,
height: self.height,
seq_param_set: sps,
pic_param_set: pps,
}),
};
w.add_track(&track).map_err(other)?;
self.writer = Some(w);
}
// Convert the Annex-B access unit to AVCC (4-byte length-prefixed NALs),
// dropping parameter sets (already in avcC) and access-unit delimiters.
let mut avcc = Vec::with_capacity(au.len());
for nal in &nals {
if nal.is_empty() {
continue;
}
match nal_type(nal) {
7 | 8 | 9 => continue, // SPS, PPS, AUD
_ => {}
}
avcc.extend_from_slice(&(nal.len() as u32).to_be_bytes());
avcc.extend_from_slice(nal);
}
if avcc.is_empty() {
return Ok(());
}
let rel_ms = {
let start = *self.start_ms.get_or_insert(ts_ms);
ts_ms.saturating_sub(start)
};
// Flush the previously buffered sample now that we know its duration.
if let Some((bytes, is_sync, prev_rel)) = self.pending.take() {
let dur = rel_ms.saturating_sub(prev_rel).clamp(1, 10_000) as u32;
self.write_sample(bytes, is_sync, prev_rel, dur)?;
}
self.pending = Some((avcc, keyframe, rel_ms));
Ok(())
}
fn write_sample(&mut self, bytes: Vec<u8>, is_sync: bool, start: u64, dur: u32) -> Result<()> {
if let Some(w) = self.writer.as_mut() {
let sample = mp4::Mp4Sample {
start_time: start,
duration: dur,
rendering_offset: 0,
is_sync,
bytes: mp4::Bytes::from(bytes),
};
w.write_sample(1, &sample).map_err(other)?;
self.samples += 1;
}
Ok(())
}
/// Flush the last sample and finalize the MP4. Returns (path, sample count).
pub fn finish(mut self) -> Result<(PathBuf, u64)> {
if let Some((bytes, is_sync, rel)) = self.pending.take() {
self.write_sample(bytes, is_sync, rel, 33)?; // nominal last-frame hold
}
if let Some(mut w) = self.writer.take() {
w.write_end().map_err(other)?;
}
Ok((self.path.clone(), self.samples))
}
pub fn samples(&self) -> u64 {
self.samples
}
}
/// Read an MP4 back and return (video track count, total video samples, width,
/// height). Used to verify a recording is a valid, parseable, playable file.
pub fn probe_mp4(path: impl AsRef<Path>) -> Result<(u32, u64, u16, u16)> {
let f = File::open(path.as_ref()).map_err(other)?;
let size = f.metadata().map_err(other)?.len();
let reader = mp4::Mp4Reader::read_header(BufReader::new(f), size).map_err(other)?;
let (mut vtracks, mut samples, mut w, mut h) = (0u32, 0u64, 0u16, 0u16);
for track in reader.tracks().values() {
if matches!(track.track_type(), Ok(mp4::TrackType::Video)) {
vtracks += 1;
samples += track.sample_count() as u64;
w = track.width();
h = track.height();
}
}
Ok((vtracks, samples, w, h))
}
+585
View File
@@ -0,0 +1,585 @@
//! WebRTC peer-connection helpers for the native P2P fast path (webrtc 0.17.2,
//! the classic tokio-callback API). This module builds the `API` + a
//! `RTCPeerConnection` from the ICE servers the relay hands us in `rtc.config`
//! / `rtc.offer.ice`. The DataChannel transport ([`crate::transport`]) and the
//! offer/answer signaling are layered on top of these.
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Result};
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::sync::mpsc;
use webrtc::api::interceptor_registry::register_default_interceptors;
use webrtc::api::media_engine::MediaEngine;
use webrtc::api::{APIBuilder, API};
use webrtc::data_channel::data_channel_init::RTCDataChannelInit;
use webrtc::data_channel::RTCDataChannel;
use webrtc::ice_transport::ice_candidate_type::RTCIceCandidateType;
use webrtc::ice_transport::ice_server::RTCIceServer;
use webrtc::interceptor::registry::Registry;
use webrtc::peer_connection::configuration::RTCConfiguration;
use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
use webrtc::peer_connection::RTCPeerConnection;
/// One ICE server exactly as it arrives in the relay's `rtc.config` reply and
/// is forwarded in `rtc.offer.ice`: `{"urls":[...],"username":...,"credential":...}`.
/// coturn REST creds (username = "<expiry>:nd", credential = base64 HMAC-SHA1)
/// drop straight into webrtc's long-term TURN credential fields.
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct RtcIceServerJson {
#[serde(default)]
pub urls: Vec<String>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub credential: Option<String>,
}
/// Map the relay's ICE JSON into webrtc `RTCIceServer`s (0.17.2 has only
/// urls/username/credential).
pub fn ice_servers_from_json(ice: &[RtcIceServerJson]) -> Vec<RTCIceServer> {
ice.iter()
.filter(|s| !s.urls.is_empty())
.map(|s| RTCIceServer {
urls: s.urls.clone(),
username: s.username.clone().unwrap_or_default(),
credential: s.credential.clone().unwrap_or_default(),
..Default::default()
})
.collect()
}
/// Build a webrtc `API` with the default media engine + interceptors. One per
/// peer connection is fine (cheap).
///
/// Raises the SCTP max send message size to 256 KiB (webrtc-rs defaults to
/// 64 KiB), matching the web client's advertised `a=max-message-size:262144`.
/// Without this a whole ~200 KiB video keyframe is rejected with
/// `ErrOutboundPacketTooLarge`; with it the native peer can send whole E2E
/// frames exactly like the browser / aiortc, which is what web/Python interop
/// on the `media` channel requires. (Harmless for the native single channel,
/// which fragments at 16 KiB.)
pub fn build_api() -> Result<API> {
use webrtc::api::setting_engine::{SctpMaxMessageSize, SettingEngine};
let mut media = MediaEngine::default();
let mut registry = Registry::new();
registry = register_default_interceptors(registry, &mut media)?;
let mut se = SettingEngine::default();
se.set_sctp_max_message_size_can_send(SctpMaxMessageSize::Bounded(262144));
Ok(APIBuilder::new()
.with_media_engine(media)
.with_interceptor_registry(registry)
.with_setting_engine(se)
.build())
}
/// A peer-connection configuration from a list of ICE servers.
pub fn rtc_config(ice_servers: Vec<RTCIceServer>) -> RTCConfiguration {
RTCConfiguration {
ice_servers,
..Default::default()
}
}
/// Create a new peer connection on `api` with `cfg`.
pub async fn new_pc(api: &API, cfg: RTCConfiguration) -> Result<Arc<RTCPeerConnection>> {
Ok(Arc::new(api.new_peer_connection(cfg).await?))
}
/// Cap on how long we wait for ICE gathering to finish before sending the SDP.
/// The web/Python peers are NON-TRICKLE (candidates must be embedded in the
/// offer/answer), so we gather to completion — but never hang on a stuck
/// network interface: 2.5 s matches the reference signaling timeout.
pub const GATHER_CAP: Duration = Duration::from_millis(2500);
/// The max DataChannel message size we advertise (bytes) — matches the web
/// client's `a=max-message-size:262144`.
pub const MAX_MESSAGE_SIZE: u32 = 262144;
/// Ensure the SDP's `m=application` section advertises our max-message-size.
/// webrtc-rs never emits `a=max-message-size`, so a peer defaults our receive
/// cap to 64 KiB and refuses to send us a whole ~200 KiB keyframe. Injecting it
/// (right after `a=sctp-port:`) raises the PEER's send-to-us cap to 256 KiB —
/// and is exactly what aiortc / the browser advertise, so it is also how we
/// receive whole video frames from the Python host over the `media` channel.
pub fn advertise_max_message_size(sdp: String) -> String {
if sdp.contains("a=max-message-size:") {
return sdp; // already present (e.g. echoed from a web peer)
}
let nl = if sdp.contains("\r\n") { "\r\n" } else { "\n" };
let needle = "a=sctp-port:";
if let Some(pos) = sdp.find(needle) {
// insert the attribute on its own line after the sctp-port line
if let Some(eol) = sdp[pos..].find('\n') {
let insert_at = pos + eol + 1;
let mut out = String::with_capacity(sdp.len() + 32);
out.push_str(&sdp[..insert_at]);
out.push_str(&format!("a=max-message-size:{MAX_MESSAGE_SIZE}{nl}"));
out.push_str(&sdp[insert_at..]);
return out;
}
}
sdp
}
/// Offerer (viewer): create the offer, then gather ICE **to completion** and
/// return the full local SDP for `rtc.offer.sdp`. Non-trickle: every candidate
/// is embedded in the returned SDP, because the Python host has no trickle
/// candidate handler. The caller must create its DataChannel(s) on `pc` BEFORE
/// calling (they shape the offer). `gathering_complete_promise` is taken before
/// `set_local_description`, which is what actually starts gathering.
pub async fn offer_nontrickle(pc: &Arc<RTCPeerConnection>, gather_cap: Duration) -> Result<String> {
let offer = pc.create_offer(None).await?;
let mut gather = pc.gathering_complete_promise().await;
pc.set_local_description(offer).await?;
let _ = tokio::time::timeout(gather_cap, gather.recv()).await;
let local = pc
.local_description()
.await
.ok_or_else(|| anyhow!("no local description after ICE gathering"))?;
Ok(advertise_max_message_size(local.sdp))
}
/// Answerer (host): apply the remote offer SDP, create the answer, gather ICE
/// to completion (non-trickle) and return the full local SDP for
/// `rtc.answer.sdp`. Register `on_data_channel` on `pc` BEFORE calling so the
/// viewer-created channel is captured.
pub async fn answer_nontrickle(
pc: &Arc<RTCPeerConnection>,
offer_sdp: &str,
gather_cap: Duration,
) -> Result<String> {
let offer = RTCSessionDescription::offer(offer_sdp.to_string())?;
pc.set_remote_description(offer).await?;
let answer = pc.create_answer(None).await?;
let mut gather = pc.gathering_complete_promise().await;
pc.set_local_description(answer).await?;
let _ = tokio::time::timeout(gather_cap, gather.recv()).await;
let local = pc
.local_description()
.await
.ok_or_else(|| anyhow!("no local description after ICE gathering"))?;
Ok(advertise_max_message_size(local.sdp))
}
/// Offerer (viewer): apply the remote answer SDP received in `rtc.answer`.
pub async fn apply_answer(pc: &Arc<RTCPeerConnection>, answer_sdp: &str) -> Result<()> {
let answer = RTCSessionDescription::answer(answer_sdp.to_string())?;
pc.set_remote_description(answer).await?;
Ok(())
}
/// Wait for the server to return rtc.config before falling back to a default
/// STUN server. 2.5 s matches the reference signaling timeout.
pub const CONFIG_CAP: Duration = Duration::from_millis(2500);
/// How long to wait for the peer's rtc.answer.
pub const ANSWER_CAP: Duration = Duration::from_secs(10);
/// How long to wait for the pc to connect + the DataChannel to open.
pub const CONNECT_CAP: Duration = Duration::from_secs(15);
/// Default ICE if the server does not answer rtc.config in time. A public STUN
/// is enough to still find a server-reflexive path; host/LAN pairs connect
/// without it (and loopback needs no ICE server at all).
pub fn default_ice() -> Vec<RtcIceServerJson> {
vec![RtcIceServerJson {
urls: vec!["stun:stun.l.google.com:19302".into()],
username: None,
credential: None,
}]
}
/// Pull rtc.* Values from `rtc_in` until one matches `want` (dropping any other
/// rtc.* that arrives first), capped by `cap`. rtc.unavailable short-circuits to
/// an error so the caller abandons the upgrade and stays on the relay.
async fn wait_rtc(
rtc_in: &mut mpsc::UnboundedReceiver<Value>,
want: &str,
cap: Duration,
) -> Result<Value> {
let deadline = tokio::time::Instant::now() + cap;
loop {
match tokio::time::timeout_at(deadline, rtc_in.recv()).await {
Ok(Some(v)) => {
let t = v.get("t").and_then(|t| t.as_str()).unwrap_or("");
if t == want {
return Ok(v);
}
if t == "rtc.unavailable" {
return Err(anyhow!("peer declined P2P (rtc.unavailable)"));
}
// ignore any other rtc.* and keep waiting
}
Ok(None) => return Err(anyhow!("rtc signaling channel closed")),
Err(_) => return Err(anyhow!("timed out waiting for {want}")),
}
}
}
/// Viewer offerer: run the relay-brokered P2P upgrade handshake and return the
/// connected pc + its DataChannel once the channel opens. `send_text` enqueues
/// an rtc.* text message onto the relay (the caller supplies one that carries
/// `sid`); `rtc_in` yields inbound rtc.config / rtc.answer / rtc.unavailable
/// Values the relay loop forwards. When `ice_override` is `None` the offerer
/// asks the server for ICE via rtc.config.req (default STUN on timeout); tests
/// pass `Some(vec![])` for a loopback host-candidate-only pc.
pub async fn upgrade_offerer(
api: &API,
sid: &str,
ice_override: Option<Vec<RtcIceServerJson>>,
channel_label: &str,
send_text: &(dyn Fn(String) + Send + Sync),
rtc_in: &mut mpsc::UnboundedReceiver<Value>,
) -> Result<(Arc<RTCPeerConnection>, Arc<RTCDataChannel>)> {
let ice = match ice_override {
Some(i) => i,
None => {
send_text(json!({ "t": "rtc.config.req", "sid": sid }).to_string());
match wait_rtc(rtc_in, "rtc.config", CONFIG_CAP).await {
Ok(v) => v
.get("iceServers")
.and_then(|x| serde_json::from_value::<Vec<RtcIceServerJson>>(x.clone()).ok())
.unwrap_or_else(default_ice),
Err(_) => default_ice(),
}
}
};
let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?;
let (open_tx, mut open_rx) = mpsc::channel::<()>(1);
let dc = pc
.create_data_channel(
channel_label,
Some(RTCDataChannelInit {
ordered: Some(true),
..Default::default()
}),
)
.await?;
{
let tx = open_tx.clone();
dc.on_open(Box::new(move || {
let tx = tx.clone();
Box::pin(async move {
let _ = tx.send(()).await;
})
}));
}
// Non-trickle: gather fully, then send the offer with the ICE config echoed
// through so a TURN-only carrier-NAT host can reach us.
let offer_sdp = offer_nontrickle(&pc, GATHER_CAP).await?;
// `native:true` marks a native single-channel offerer: a native host only
// answers these, and declines (rtc.unavailable) a web/3-channel offerer so
// that pairing safely stays on the relay.
send_text(
json!({ "t": "rtc.offer", "sid": sid, "sdp": offer_sdp, "ice": ice, "native": true })
.to_string(),
);
let answer = wait_rtc(rtc_in, "rtc.answer", ANSWER_CAP).await?;
let answer_sdp = answer
.get("sdp")
.and_then(|s| s.as_str())
.ok_or_else(|| anyhow!("rtc.answer missing sdp"))?;
apply_answer(&pc, answer_sdp).await?;
tokio::time::timeout(CONNECT_CAP, open_rx.recv())
.await
.map_err(|_| anyhow!("P2P DataChannel did not open"))?
.ok_or_else(|| anyhow!("P2P open signal dropped"))?;
Ok((pc, dc))
}
/// Host answerer: handle one inbound rtc.offer. Build the answerer pc from the
/// offer plus the `ice` the offerer echoed, capture the viewer-created channel
/// via on_data_channel, create+send the answer via `send_text` (carrying
/// `sid`), and return the connected pc + channel once it opens.
pub async fn answer_offer(
api: &API,
sid: &str,
offer_sdp: &str,
ice: Vec<RtcIceServerJson>,
send_text: &(dyn Fn(String) + Send + Sync),
) -> Result<(Arc<RTCPeerConnection>, Arc<RTCDataChannel>)> {
let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?;
let (dc_tx, mut dc_rx) = mpsc::channel::<Arc<RTCDataChannel>>(1);
let (open_tx, mut open_rx) = mpsc::channel::<()>(1);
{
let dc_tx = dc_tx.clone();
let open_tx = open_tx.clone();
pc.on_data_channel(Box::new(move |d: Arc<RTCDataChannel>| {
let dc_tx = dc_tx.clone();
let open_tx = open_tx.clone();
Box::pin(async move {
let ot = open_tx.clone();
d.on_open(Box::new(move || {
let ot = ot.clone();
Box::pin(async move {
let _ = ot.send(()).await;
})
}));
let _ = dc_tx.send(d).await;
})
}));
}
let answer_sdp = answer_nontrickle(&pc, offer_sdp, GATHER_CAP).await?;
send_text(json!({ "t": "rtc.answer", "sid": sid, "sdp": answer_sdp }).to_string());
let dc = tokio::time::timeout(CONNECT_CAP, dc_rx.recv())
.await
.map_err(|_| anyhow!("no DataChannel from the offerer"))?
.ok_or_else(|| anyhow!("DataChannel signal dropped"))?;
tokio::time::timeout(CONNECT_CAP, open_rx.recv())
.await
.map_err(|_| anyhow!("P2P DataChannel did not open (host)"))?
.ok_or_else(|| anyhow!("host open signal dropped"))?;
Ok((pc, dc))
}
/// True when the pc's nominated ICE pair is a DIRECT path (host / server-
/// reflexive on both ends) rather than a TURN relay. The relay-first state
/// machine only reports "Direct P2P" and moves media off the WSS relay for a
/// direct pair: a TURN-relayed hop is no faster than our own relay, and a
/// not-yet-selected pair is unknown, so both keep media on the relay.
pub async fn selected_pair_is_direct(pc: &Arc<RTCPeerConnection>) -> bool {
let dtls = pc.sctp().transport();
let ice = dtls.ice_transport();
match ice.get_selected_candidate_pair().await {
Some(pair) => {
pair.local.typ != RTCIceCandidateType::Relay
&& pair.remote.typ != RTCIceCandidateType::Relay
}
None => false,
}
}
/// The web/Python 3-channel DataChannel topology. Labels + options match
/// `app.js` exactly so a native peer interops with the browser and the Python
/// host: `media` carries video (unreliable, 150 ms lifetime — drop beats late),
/// `ctl` carries input/clipboard/file/keyframe (reliable, ordered), `move`
/// carries pointer motion (unreliable, no retransmit).
pub struct WebChannels {
pub media: Arc<RTCDataChannel>,
pub ctl: Arc<RTCDataChannel>,
pub mov: Arc<RTCDataChannel>,
}
/// Offerer: create the three web channels on `pc`. Call BEFORE `offer_nontrickle`
/// so they are announced in the SDP. The option set is byte-for-byte the web
/// client's (`media`{ordered:false,maxPacketLifeTime:150},
/// `ctl`{ordered:true}, `move`{ordered:false,maxRetransmits:0}).
pub async fn create_web_channels(pc: &Arc<RTCPeerConnection>) -> Result<WebChannels> {
let media = pc
.create_data_channel(
"media",
Some(RTCDataChannelInit {
ordered: Some(false),
max_packet_life_time: Some(150),
..Default::default()
}),
)
.await?;
let ctl = pc
.create_data_channel(
"ctl",
Some(RTCDataChannelInit {
ordered: Some(true),
..Default::default()
}),
)
.await?;
let mov = pc
.create_data_channel(
"move",
Some(RTCDataChannelInit {
ordered: Some(false),
max_retransmits: Some(0),
..Default::default()
}),
)
.await?;
Ok(WebChannels { media, ctl, mov })
}
/// Like [`build_api`] but with DETACHED DataChannels enabled. In detach mode we
/// read each channel with our OWN buffer instead of webrtc-rs's fixed 64 KiB
/// `read_loop` buffer — which is the ONLY way to receive a whole >64 KiB
/// DataChannel message on webrtc-rs 0.17.2. This is required for the web/Python
/// `media` channel, where the browser / aiortc send whole ~100-200 KiB video
/// frames. Use this API for the web 3-channel topology; [`build_api`] (on_message,
/// 64 KiB) stays the default for the native single channel, which fragments at
/// 16 KiB and never needs a large receive.
pub fn build_api_web() -> Result<API> {
use webrtc::api::setting_engine::{SctpMaxMessageSize, SettingEngine};
let mut media = MediaEngine::default();
let mut registry = Registry::new();
registry = register_default_interceptors(registry, &mut media)?;
let mut se = SettingEngine::default();
se.set_sctp_max_message_size_can_send(SctpMaxMessageSize::Bounded(MAX_MESSAGE_SIZE));
se.detach_data_channels();
Ok(APIBuilder::new()
.with_media_engine(media)
.with_interceptor_registry(registry)
.with_setting_engine(se)
.build())
}
/// The web DataChannels the viewer actually uses, after detaching (raw
/// read/write handles with our own buffer — the only way to receive whole
/// >64KiB frames on webrtc-rs 0.17.2). `move` is created for topology/interop
/// but the viewer never reads/writes it, so it is not detached (fewer detaches
/// = more reliable).
pub struct WebDetached {
pub media: Arc<webrtc::data::data_channel::DataChannel>,
pub ctl: Arc<webrtc::data::data_channel::DataChannel>,
}
/// Viewer offerer, WEB topology (interop with the browser / Python host). Builds
/// the 3 channels (media/ctl/move, labels+options per app.js), runs the
/// non-trickle rtc.offer/answer handshake over `send_text`+`rtc_in` (offer does
/// NOT carry `native` — it must look like a standard web offer so the Python
/// host accepts it), waits for all 3 to open, then detaches them SEQUENTIALLY
/// (concurrent multi-channel detach deadlocks) and returns the raw handles. The
/// pc must be built from [`build_api_web`] (detach enabled).
pub async fn upgrade_offerer_web(
api: &API,
sid: &str,
ice_override: Option<Vec<RtcIceServerJson>>,
send_text: &(dyn Fn(String) + Send + Sync),
rtc_in: &mut mpsc::UnboundedReceiver<Value>,
) -> Result<(Arc<RTCPeerConnection>, WebDetached)> {
let ice = match ice_override {
Some(i) => i,
None => {
send_text(json!({ "t": "rtc.config.req", "sid": sid }).to_string());
match wait_rtc(rtc_in, "rtc.config", CONFIG_CAP).await {
Ok(v) => v
.get("iceServers")
.and_then(|x| serde_json::from_value::<Vec<RtcIceServerJson>>(x.clone()).ok())
.unwrap_or_else(default_ice),
Err(_) => default_ice(),
}
}
};
let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?;
let web = create_web_channels(&pc).await?;
// open signals for the three channels
let (open_tx, mut open_rx) = mpsc::unbounded_channel::<&'static str>();
for (lbl, ch) in [
("media", &web.media),
("ctl", &web.ctl),
("move", &web.mov),
] {
let tx = open_tx.clone();
ch.on_open(Box::new(move || {
let tx = tx.clone();
Box::pin(async move {
let _ = tx.send(lbl);
})
}));
}
let offer_sdp = offer_nontrickle(&pc, GATHER_CAP).await?;
// NOTE: no `native` marker — this is a standard web offer.
send_text(json!({ "t": "rtc.offer", "sid": sid, "sdp": offer_sdp, "ice": ice }).to_string());
let answer = wait_rtc(rtc_in, "rtc.answer", ANSWER_CAP).await?;
let answer_sdp = answer
.get("sdp")
.and_then(|s| s.as_str())
.ok_or_else(|| anyhow!("rtc.answer missing sdp"))?;
apply_answer(&pc, answer_sdp).await?;
// wait for the two channels we use (media+ctl) to open, capped.
let mut opened = std::collections::HashSet::new();
let deadline = tokio::time::Instant::now() + CONNECT_CAP;
while !(opened.contains("media") && opened.contains("ctl")) {
match tokio::time::timeout_at(deadline, open_rx.recv()).await {
Ok(Some(l)) => {
opened.insert(l);
}
_ => return Err(anyhow!("web channels did not open")),
}
}
// detach SEQUENTIALLY (concurrent detach deadlocks) — only media + ctl.
let media = web.media.detach().await?;
let ctl = web.ctl.detach().await?;
Ok((pc, WebDetached { media, ctl }))
}
/// Host answerer, WEB topology (a browser / web viewer connects to a native
/// host). Builds the answerer pc, binds the viewer-created media/ctl/move
/// channels by label, runs the non-trickle answer, waits for media+ctl to open,
/// then detaches them SEQUENTIALLY. The pc must be built from [`build_api_web`].
/// The host then SENDS video on media (whole frames, cap 256KiB) and READS
/// input on ctl.
pub async fn answer_offer_web(
api: &API,
sid: &str,
offer_sdp: &str,
ice: Vec<RtcIceServerJson>,
send_text: &(dyn Fn(String) + Send + Sync),
) -> Result<(Arc<RTCPeerConnection>, WebDetached)> {
use std::collections::HashMap;
use std::sync::Mutex as StdMutex;
let pc = new_pc(api, rtc_config(ice_servers_from_json(&ice))).await?;
// bind channels by label + signal opens
let chans: Arc<StdMutex<HashMap<String, Arc<RTCDataChannel>>>> =
Arc::new(StdMutex::new(HashMap::new()));
let (open_tx, mut open_rx) = mpsc::unbounded_channel::<String>();
{
let chans = chans.clone();
let open_tx = open_tx.clone();
pc.on_data_channel(Box::new(move |d: Arc<RTCDataChannel>| {
let chans = chans.clone();
let open_tx = open_tx.clone();
let label = d.label().to_string();
Box::pin(async move {
chans.lock().unwrap().insert(label.clone(), d.clone());
let tx = open_tx.clone();
let l = label.clone();
d.on_open(Box::new(move || {
let tx = tx.clone();
let l = l.clone();
Box::pin(async move {
let _ = tx.send(l);
})
}));
})
}));
}
let answer_sdp = answer_nontrickle(&pc, offer_sdp, GATHER_CAP).await?;
send_text(json!({ "t": "rtc.answer", "sid": sid, "sdp": answer_sdp }).to_string());
// wait for media + ctl to open, capped
let mut opened = std::collections::HashSet::new();
let deadline = tokio::time::Instant::now() + CONNECT_CAP;
while !(opened.contains("media") && opened.contains("ctl")) {
match tokio::time::timeout_at(deadline, open_rx.recv()).await {
Ok(Some(l)) => {
opened.insert(l);
}
_ => return Err(anyhow!("web channels (media/ctl) did not open")),
}
}
// grab wrappers, then detach SEQUENTIALLY (concurrent detach deadlocks).
let (media_w, ctl_w) = {
let m = chans.lock().unwrap();
(
m.get("media").cloned().ok_or_else(|| anyhow!("no media channel"))?,
m.get("ctl").cloned().ok_or_else(|| anyhow!("no ctl channel"))?,
)
};
let media = media_w.detach().await?;
let ctl = ctl_w.detach().await?;
Ok((pc, WebDetached { media, ctl }))
}
File diff suppressed because it is too large. Load diff
+471
View File
@@ -0,0 +1,471 @@
//! Pluggable transport for the session protocol. The E2E AES-GCM framing sits
//! ABOVE this layer on both the viewer and host, so a transport only ever moves
//! opaque frames (binary media/file chunks, or text control JSON). The current
//! WSS relay ([`WsRelay`]) and the P2P WebRTC DataChannel (added later) both
//! implement [`Transport`], so `connect_with` / `run_host_with` carry either and
//! the relay→P2P upgrade / downgrade can swap transports without touching the
//! protocol, crypto, or framing.
use crate::error::{NdError, Result};
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use bytes::Bytes;
use webrtc::data_channel::data_channel_message::DataChannelMessage;
use webrtc::data_channel::RTCDataChannel;
/// One message crossing a transport. Opaque here; the caller encrypts/decrypts
/// and dispatches by channel byte above this layer.
#[derive(Debug, Clone)]
pub enum TransportMsg {
Binary(Vec<u8>),
Text(String),
}
/// Which logical stream a binary frame belongs to. The caller knows this from
/// the (pre-encryption) channel byte; the transport cannot see it because the
/// byte is inside the E2E ciphertext. It decides per-lane delivery: `Media` is
/// drop-tolerant (video — drop a fresh frame rather than queue it late), `Ctl`
/// must be reliable (file bytes, control), `Move` is drop-tolerant pointer
/// motion. Maps 1:1 to the web/Python `media` / `ctl` / `move` DataChannels;
/// the relay and the native single channel use it only for back-pressure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lane {
Media,
Ctl,
Move,
}
/// A bidirectional message transport. Used from a single task (the viewer's
/// socket task / the host's `run_host` loop): `recv` is awaited in one
/// `tokio::select!` arm and `send_*` in other arms' bodies, so `&mut self` for
/// all three is sound (select drops the non-chosen futures first).
#[async_trait]
pub trait Transport: Send + 'static {
async fn send_binary(&mut self, data: Vec<u8>) -> Result<()>;
async fn send_text(&mut self, text: String) -> Result<()>;
/// Next inbound message, or `None` when the transport has closed.
async fn recv(&mut self) -> Option<Result<TransportMsg>>;
/// Send a binary frame tagged with its [`Lane`]. The default ignores the
/// lane (relay, native single channel today), so existing transports need
/// no change; the P2P multiplexer overrides it to pick the channel /
/// back-pressure policy. Callers that know the channel (video vs file)
/// should prefer this over [`Transport::send_binary`].
async fn send_binary_lane(&mut self, data: Vec<u8>, lane: Lane) -> Result<()> {
let _ = lane;
self.send_binary(data).await
}
}
/// The WSS relay transport — the current path, behaviourally identical to the
/// pre-seam code. Wraps the tungstenite WebSocket (TLS to the relay); the server
/// relays frames opaquely between the two session peers.
pub struct WsRelay {
ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
}
impl WsRelay {
/// Dial the relay (TLS). Mirrors the old `connect_async` + error mapping.
pub async fn connect(ws_url: &str) -> Result<Self> {
let (ws, _resp) = connect_async(ws_url)
.await
.map_err(|e| NdError::Connect(e.to_string()))?;
Ok(Self { ws })
}
/// Wrap an already-connected WebSocket (e.g. one opened during a handshake
/// elsewhere).
pub fn from_ws(ws: WebSocketStream<MaybeTlsStream<TcpStream>>) -> Self {
Self { ws }
}
}
#[async_trait]
impl Transport for WsRelay {
async fn send_binary(&mut self, data: Vec<u8>) -> Result<()> {
self.ws
.send(Message::Binary(data))
.await
.map_err(|e| NdError::Connect(e.to_string()))
}
async fn send_text(&mut self, text: String) -> Result<()> {
self.ws
.send(Message::Text(text))
.await
.map_err(|e| NdError::Connect(e.to_string()))
}
async fn recv(&mut self) -> Option<Result<TransportMsg>> {
loop {
match self.ws.next().await {
Some(Ok(Message::Binary(b))) => return Some(Ok(TransportMsg::Binary(b))),
Some(Ok(Message::Text(t))) => return Some(Ok(TransportMsg::Text(t))),
Some(Ok(Message::Close(_))) | None => return None,
// Ping/Pong/Frame are handled by tungstenite; skip at this layer.
Some(Ok(_)) => continue,
Some(Err(e)) => return Some(Err(NdError::Connect(e.to_string()))),
}
}
}
}
// ============================ P2P WebRTC DataChannel ============================
/// 9-byte fragment header prepended to every binary chunk:
/// `[frame_id:u32 BE][idx:u16 BE][count:u16 BE][flags:u8]`. `flags` bit0 is
/// reserved for a keyframe hint (set by a higher layer in a later phase); the
/// reassembler ignores it.
const FRAG_HEADER: usize = 9;
/// Each SCTP message stays at or below 16 KiB (header + payload) — safely under
/// the negotiated max-message-size, and small enough that one slow frame never
/// head-of-line-blocks the queue for long.
const MAX_CHUNK_PAYLOAD: usize = 16 * 1024 - FRAG_HEADER;
/// Pause sending once this much is queued in the SCTP send buffer...
const HIGH_WATER: usize = 1024 * 1024;
/// ...and resume when it drains to here (`on_buffered_amount_low` fires).
const LOW_WATER: usize = 512 * 1024;
/// Reassembly accumulator for one multi-chunk binary frame.
struct FrameAsm {
count: u16,
got: u16,
parts: Vec<Option<Vec<u8>>>,
}
impl FrameAsm {
fn new(count: u16) -> Self {
Self {
count,
got: 0,
parts: vec![None; count as usize],
}
}
fn put(&mut self, idx: u16, payload: &[u8]) {
let i = idx as usize;
if i < self.parts.len() && self.parts[i].is_none() {
self.parts[i] = Some(payload.to_vec());
self.got += 1;
}
}
fn complete(&self) -> bool {
self.got == self.count
}
/// Concatenate the chunks in index order. Because the chunks are contiguous
/// slices of the original nonce||ciphertext||tag blob, the rejoined bytes
/// are byte-identical — a mis-ordered or missing chunk would corrupt the
/// blob and the viewer AES-GCM decrypt_frame would fail the auth tag, which
/// is exactly the integrity guarantee we want.
fn concat(self) -> Vec<u8> {
let mut out = Vec::new();
for p in self.parts.into_iter().flatten() {
out.extend_from_slice(&p);
}
out
}
}
/// Install the inbound reassembly handler on `dc`: SCTP string messages become
/// `TransportMsg::Text`; fragmented binary chunks are rejoined by `frame_id`
/// and pushed as exactly one contiguous `TransportMsg::Binary` (so the E2E
/// `nonce||ct||tag` blob stays intact for AES-GCM). Shared by `WebRtcChannel`
/// (direct P2P) and the relay/P2P multiplexer so both frame the wire identically.
pub fn install_reassembler(dc: &Arc<RTCDataChannel>, out: mpsc::UnboundedSender<TransportMsg>) {
let reasm: Arc<Mutex<HashMap<u32, FrameAsm>>> = Arc::new(Mutex::new(HashMap::new()));
dc.on_message(Box::new(move |msg: DataChannelMessage| {
let out = out.clone();
let reasm = reasm.clone();
Box::pin(async move {
if msg.is_string {
if let Ok(s) = String::from_utf8(msg.data.to_vec()) {
let _ = out.send(TransportMsg::Text(s));
}
return;
}
let data = &msg.data;
if data.len() < FRAG_HEADER {
return; // malformed; drop
}
let frame_id = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
let idx = u16::from_be_bytes([data[4], data[5]]);
let count = u16::from_be_bytes([data[6], data[7]]);
// data[8] = flags (bit0 = keyframe), reserved; unused for reassembly.
let payload = &data[FRAG_HEADER..];
match count {
0 => {}
1 => {
let _ = out.send(TransportMsg::Binary(payload.to_vec()));
}
_ => {
let mut map = reasm.lock().unwrap();
let entry = map.entry(frame_id).or_insert_with(|| FrameAsm::new(count));
entry.put(idx, payload);
if entry.complete() {
if let Some(asm) = map.remove(&frame_id) {
drop(map);
let _ = out.send(TransportMsg::Binary(asm.concat()));
}
}
}
}
})
}));
}
/// Fragment `data` into `<=16 KiB` SCTP messages (9-byte header each) under one
/// `frame_id` and send them in order on `dc`. No back-pressure here — the caller
/// applies its own policy (wait vs drop-oldest) around this. Returns the error
/// from the first failed `dc.send`.
pub async fn send_fragmented(dc: &Arc<RTCDataChannel>, frame_id: u32, data: &[u8]) -> Result<()> {
let total = data.len();
let count = ((total + MAX_CHUNK_PAYLOAD - 1) / MAX_CHUNK_PAYLOAD).max(1);
if count > u16::MAX as usize {
return Err(NdError::Connect(format!(
"frame too large to fragment: {total} bytes"
)));
}
for idx in 0..count {
let start = idx * MAX_CHUNK_PAYLOAD;
let end = (start + MAX_CHUNK_PAYLOAD).min(total);
let chunk = &data[start..end];
let mut framed = Vec::with_capacity(FRAG_HEADER + chunk.len());
framed.extend_from_slice(&frame_id.to_be_bytes());
framed.extend_from_slice(&(idx as u16).to_be_bytes());
framed.extend_from_slice(&(count as u16).to_be_bytes());
framed.push(0u8); // flags (bit0 = keyframe) reserved
framed.extend_from_slice(chunk);
dc.send(&Bytes::from(framed))
.await
.map_err(|e| NdError::Connect(e.to_string()))?;
}
Ok(())
}
/// A [`Transport`] over a single reliable+ordered WebRTC DataChannel — the
/// native-to-native direct P2P fast path. The E2E AES-GCM framing is unchanged
/// above this layer; this channel only moves opaque binary frames (fragmented
/// to fit SCTP, reassembled whole so the GCM blob stays contiguous) and opaque
/// text control JSON (sent as SCTP string messages, so the peer tells the two
/// apart with `DataChannelMessage::is_string`).
pub struct WebRtcChannel {
dc: Arc<RTCDataChannel>,
inbound: mpsc::UnboundedReceiver<TransportMsg>,
/// Drains one token whenever the SCTP send buffer falls below `LOW_WATER`.
low_water_rx: mpsc::Receiver<()>,
/// Set true by `on_close`; makes `recv` return `None` once inbound is empty.
closed_flag: Arc<AtomicBool>,
closed_rx: mpsc::Receiver<()>,
next_frame_id: u32,
}
impl WebRtcChannel {
/// Wrap an open DataChannel. Registers the inbound reassembly handler, the
/// back-pressure low-water signal, and the close signal. Call once the
/// channel is open (sends before open error at the SCTP layer).
pub async fn new(dc: Arc<RTCDataChannel>) -> Self {
let (in_tx, in_rx) = mpsc::unbounded_channel::<TransportMsg>();
// --- inbound: reassemble binary frames, pass text straight through.
install_reassembler(&dc, in_tx);
// --- back-pressure: a size-1 mpsc is race-free vs Notify here. The
// low-water event only fires when the buffer crosses DOWN through the
// threshold, so a token can be pending at most once; try_send drops
// extras.
let (low_tx, low_rx) = mpsc::channel::<()>(1);
dc.set_buffered_amount_low_threshold(LOW_WATER).await;
dc.on_buffered_amount_low(Box::new(move || {
let low_tx = low_tx.clone();
Box::pin(async move {
let _ = low_tx.try_send(());
})
}))
.await;
// --- close signal so recv() can return None on a dropped channel.
let closed_flag = Arc::new(AtomicBool::new(false));
let (closed_tx, closed_rx) = mpsc::channel::<()>(1);
let cf = closed_flag.clone();
dc.on_close(Box::new(move || {
let cf = cf.clone();
let closed_tx = closed_tx.clone();
Box::pin(async move {
cf.store(true, Ordering::SeqCst);
let _ = closed_tx.try_send(());
})
}));
Self {
dc,
inbound: in_rx,
low_water_rx: low_rx,
closed_flag,
closed_rx,
next_frame_id: 0,
}
}
/// The underlying channel, for stats / state inspection (upgrade logic).
pub fn data_channel(&self) -> &Arc<RTCDataChannel> {
&self.dc
}
}
#[async_trait]
impl Transport for WebRtcChannel {
async fn send_binary(&mut self, data: Vec<u8>) -> Result<()> {
// Pause if the SCTP send queue is already deep: wait for it to drain
// below LOW_WATER (fresh frames never pile up unbounded in RAM).
while self.dc.buffered_amount().await + FRAG_HEADER + data.len() > HIGH_WATER {
if self.low_water_rx.recv().await.is_none() {
break;
}
}
let frame_id = self.next_frame_id;
self.next_frame_id = self.next_frame_id.wrapping_add(1);
send_fragmented(&self.dc, frame_id, &data).await
}
async fn send_text(&mut self, text: String) -> Result<()> {
self.dc
.send_text(text)
.await
.map_err(|e| NdError::Connect(e.to_string()))?;
Ok(())
}
async fn recv(&mut self) -> Option<Result<TransportMsg>> {
if self.closed_flag.load(Ordering::SeqCst) {
// Drain anything already reassembled, then report closed.
return self.inbound.try_recv().ok().map(Ok);
}
tokio::select! {
biased;
msg = self.inbound.recv() => msg.map(Ok),
_ = self.closed_rx.recv() => self.inbound.try_recv().ok().map(Ok),
}
}
}
/// Direct-IP transport — a raw TCP socket instead of the relay. The viewer dials
/// the host's `IP:port` and the session runs the SAME challenge/auth + E2E
/// AES-GCM above this layer (so direct-IP is encrypted, unlike RustDesk's).
/// Framing is `[type:u8][len:u32 BE][payload]` (type 0 = binary, 1 = text). A
/// dedicated reader task parses frames into a channel, so `recv` is
/// cancellation-safe in the caller's `select!` (a bare `read_exact` is not).
pub struct DirectTcp {
writer: tokio::net::tcp::OwnedWriteHalf,
rx: mpsc::UnboundedReceiver<Result<TransportMsg>>,
}
/// Cap on a single framed message (generous for a 4K keyframe).
const DIRECT_MAX_FRAME: usize = 64 * 1024 * 1024;
impl DirectTcp {
/// Wrap an accepted / dialed TCP stream.
pub fn new(stream: TcpStream) -> Self {
let _ = stream.set_nodelay(true);
let (mut read, write) = stream.into_split();
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
let mut hdr = [0u8; 5];
loop {
if read.read_exact(&mut hdr).await.is_err() {
break;
}
let ty = hdr[0];
let len = u32::from_be_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize;
if len > DIRECT_MAX_FRAME {
break;
}
let mut buf = vec![0u8; len];
if read.read_exact(&mut buf).await.is_err() {
break;
}
let msg = if ty == 1 {
TransportMsg::Text(String::from_utf8_lossy(&buf).into_owned())
} else {
TransportMsg::Binary(buf)
};
if tx.send(Ok(msg)).is_err() {
break;
}
}
});
DirectTcp { writer: write, rx }
}
async fn write_frame(&mut self, ty: u8, payload: &[u8]) -> Result<()> {
use tokio::io::AsyncWriteExt;
let mut hdr = [0u8; 5];
hdr[0] = ty;
hdr[1..5].copy_from_slice(&(payload.len() as u32).to_be_bytes());
self.writer
.write_all(&hdr)
.await
.map_err(|e| NdError::Connect(e.to_string()))?;
self.writer
.write_all(payload)
.await
.map_err(|e| NdError::Connect(e.to_string()))?;
Ok(())
}
}
#[async_trait]
impl Transport for DirectTcp {
async fn send_binary(&mut self, data: Vec<u8>) -> Result<()> {
self.write_frame(0, &data).await
}
async fn send_text(&mut self, text: String) -> Result<()> {
self.write_frame(1, text.as_bytes()).await
}
async fn recv(&mut self) -> Option<Result<TransportMsg>> {
self.rx.recv().await
}
}
#[cfg(test)]
mod direct_tests {
use super::*;
// DirectTcp framing must round-trip text + binary, in order, over a real
// loopback socket (the foundation of encrypted direct-IP access).
#[tokio::test]
async fn direct_tcp_roundtrips_text_and_binary_in_order() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let srv = tokio::spawn(async move {
let (s, _) = listener.accept().await.unwrap();
let mut t = DirectTcp::new(s);
for _ in 0..3 {
match t.recv().await.unwrap().unwrap() {
TransportMsg::Text(s) => t.send_text(format!("echo:{s}")).await.unwrap(),
TransportMsg::Binary(b) => {
let mut r = b.clone();
r.reverse();
t.send_binary(r).await.unwrap();
}
}
}
});
let cs = tokio::net::TcpStream::connect(addr).await.unwrap();
let mut c = DirectTcp::new(cs);
c.send_text("hello".into()).await.unwrap();
c.send_binary(vec![1, 2, 3, 4]).await.unwrap();
c.send_text("world".into()).await.unwrap();
assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Text(s) if s == "echo:hello"));
assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Binary(b) if b == vec![4, 3, 2, 1]));
assert!(matches!(c.recv().await.unwrap().unwrap(), TransportMsg::Text(s) if s == "echo:world"));
srv.await.unwrap();
}
}
+133
View File
@@ -0,0 +1,133 @@
//! Self-update support: a tiny release manifest the client fetches to learn the
//! latest version, where to download it, and its SHA-256 — so an update is only
//! ever applied after the bytes are verified. RustDesk-style sha256-checked
//! auto-update, distilled to pure, testable helpers.
//!
//! The server publishes `version.json` alongside the installer (served by the
//! existing `/download/<name>` static route — no server code change needed):
//! ```json
//! { "version": "0.2.0",
//! "url": "https://netdesk.space/download/NetDesk-Setup.exe",
//! "sha256": "<hex>", "notes": "what changed" }
//! ```
use crate::error::{NdError, Result};
use serde::Deserialize;
use sha2::{Digest, Sha256};
/// A parsed release manifest.
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub version: String,
pub url: String,
#[serde(default)]
pub sha256: String,
#[serde(default)]
pub notes: String,
}
/// Parse the `version.json` manifest.
pub fn parse_manifest(json: &str) -> Result<Manifest> {
serde_json::from_str(json).map_err(|e| NdError::Other(anyhow::anyhow!("manifest: {e}")))
}
/// Parse a dotted `x.y.z` version into a comparable tuple. Extra components are
/// ignored; a missing/garbage component is treated as 0 (so a malformed version
/// never reads as "newer" and triggers a spurious update).
fn parse_ver(v: &str) -> (u64, u64, u64) {
let mut it = v
.trim()
.trim_start_matches('v')
.split(|c| c == '.' || c == '-' || c == '+')
.map(|p| p.parse::<u64>().unwrap_or(0));
(
it.next().unwrap_or(0),
it.next().unwrap_or(0),
it.next().unwrap_or(0),
)
}
/// True when `latest` is strictly newer than `current` (semantic x.y.z compare).
/// Equal or older → false, so re-checking never re-triggers.
pub fn is_newer(current: &str, latest: &str) -> bool {
parse_ver(latest) > parse_ver(current)
}
/// Verify a downloaded artifact against the manifest's SHA-256 (hex, any case).
/// An empty expected hash fails closed (we never install unverified bytes).
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> bool {
if expected_hex.trim().is_empty() {
return false;
}
let mut h = Sha256::new();
h.update(bytes);
let got = hex::encode(h.finalize());
// length-independent constant-time-ish compare on the lowercased hex
let exp = expected_hex.trim().to_lowercase();
got.len() == exp.len()
&& got
.bytes()
.zip(exp.bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
== 0
}
/// Decide whether to update given the running version + a fetched manifest.
/// Returns Some(manifest) only when the manifest names a strictly newer version
/// AND carries a sha256 (we refuse to "update" to an unverifiable artifact).
pub fn update_available(current: &str, manifest: &Manifest) -> bool {
!manifest.sha256.trim().is_empty() && is_newer(current, &manifest.version)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_compare() {
assert!(is_newer("0.1.0", "0.2.0"));
assert!(is_newer("0.1.0", "0.1.1"));
assert!(is_newer("1.9.9", "2.0.0"));
assert!(!is_newer("0.2.0", "0.2.0")); // equal
assert!(!is_newer("0.2.0", "0.1.9")); // older
assert!(!is_newer("0.2.0", "garbage")); // malformed -> not newer
assert!(is_newer("v0.1.0", "v0.1.1")); // tolerate a 'v' prefix
}
#[test]
fn sha256_verify_roundtrip() {
let data = b"NetDesk-Setup.exe bytes";
let mut h = Sha256::new();
h.update(data);
let good = hex::encode(h.finalize());
assert!(verify_sha256(data, &good));
assert!(verify_sha256(data, &good.to_uppercase())); // case-insensitive
assert!(!verify_sha256(data, "")); // empty fails closed
assert!(!verify_sha256(data, &"0".repeat(64))); // wrong hash
assert!(!verify_sha256(b"tampered", &good)); // tampered bytes
}
#[test]
fn update_available_needs_newer_and_hash() {
let m = |ver: &str, sha: &str| Manifest {
version: ver.into(),
url: "https://x/NetDesk-Setup.exe".into(),
sha256: sha.into(),
notes: String::new(),
};
assert!(update_available("0.1.0", &m("0.2.0", "abcd")));
assert!(!update_available("0.1.0", &m("0.2.0", "")), "no hash -> refuse");
assert!(!update_available("0.2.0", &m("0.2.0", "abcd")), "not newer");
}
#[test]
fn manifest_parses() {
let m = parse_manifest(
r#"{"version":"0.3.0","url":"https://netdesk.space/download/NetDesk-Setup.exe","sha256":"deadbeef","notes":"x"}"#,
)
.unwrap();
assert_eq!(m.version, "0.3.0");
assert!(m.url.ends_with("NetDesk-Setup.exe"));
assert_eq!(m.sha256, "deadbeef");
}
}
+420
View File
@@ -0,0 +1,420 @@
//! Pure-Rust WebM (Matroska/EBML) muxer for session recording — remuxes the
//! received VP9 video frames + Opus audio packets into a playable `.webm` with
//! **no re-encode** (just like `recorder::Mp4Recorder` does for H.264 → MP4).
//!
//! Why WebM and not MP4: our audio is Opus and the `mp4` crate is AAC-only (and
//! we have no AAC encoder), so Opus can't ride an MP4 track. VP9 + Opus are both
//! WebM-native, so a WebM container tees both received streams with zero
//! transcoding. This is a hand-rolled EBML writer (no C++ libwebm / no mingw
//! dependency): an EBML header, a streaming Segment (unknown size), Info,
//! Tracks (V_VP9 + optional A_OPUS), then live Clusters of SimpleBlocks.
//!
//! Developed by Akronlogic.
#![allow(clippy::unusual_byte_groupings)]
use crate::error::{NdError, Result};
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
fn other(msg: impl std::fmt::Display) -> NdError {
NdError::Other(anyhow::anyhow!("webm: {msg}"))
}
// ---- EBML element IDs (written as their literal bytes; the ID already encodes
// its own length in the leading bits) ----
const ID_EBML: &[u8] = &[0x1A, 0x45, 0xDF, 0xA3];
const ID_EBMLVERSION: &[u8] = &[0x42, 0x86];
const ID_EBMLREADVERSION: &[u8] = &[0x42, 0xF7];
const ID_EBMLMAXIDLENGTH: &[u8] = &[0x42, 0xF2];
const ID_EBMLMAXSIZELENGTH: &[u8] = &[0x42, 0xF3];
const ID_DOCTYPE: &[u8] = &[0x42, 0x82];
const ID_DOCTYPEVERSION: &[u8] = &[0x42, 0x87];
const ID_DOCTYPEREADVERSION: &[u8] = &[0x42, 0x85];
const ID_SEGMENT: &[u8] = &[0x18, 0x53, 0x80, 0x67];
const ID_INFO: &[u8] = &[0x15, 0x49, 0xA9, 0x66];
const ID_TIMECODESCALE: &[u8] = &[0x2A, 0xD7, 0xB1];
const ID_MUXINGAPP: &[u8] = &[0x4D, 0x80];
const ID_WRITINGAPP: &[u8] = &[0x57, 0x41];
const ID_TRACKS: &[u8] = &[0x16, 0x54, 0xAE, 0x6B];
const ID_TRACKENTRY: &[u8] = &[0xAE];
const ID_TRACKNUMBER: &[u8] = &[0xD7];
const ID_TRACKUID: &[u8] = &[0x73, 0xC5];
const ID_TRACKTYPE: &[u8] = &[0x83];
const ID_CODECID: &[u8] = &[0x86];
const ID_CODECPRIVATE: &[u8] = &[0x63, 0xA2];
const ID_VIDEO: &[u8] = &[0xE0];
const ID_PIXELWIDTH: &[u8] = &[0xB0];
const ID_PIXELHEIGHT: &[u8] = &[0xBA];
const ID_AUDIO: &[u8] = &[0xE1];
const ID_SAMPLINGFREQ: &[u8] = &[0xB5];
const ID_CHANNELS: &[u8] = &[0x9F];
const ID_CLUSTER: &[u8] = &[0x1F, 0x43, 0xB6, 0x75];
const ID_TIMECODE: &[u8] = &[0xE7];
const ID_SIMPLEBLOCK: &[u8] = &[0xA3];
/// EBML variable-length integer encoding `value` as a size/number field.
fn vint(value: u64) -> Vec<u8> {
for n in 1..=8u32 {
if value < (1u64 << (7 * n)) - 1 {
let v = value | (1u64 << (7 * n));
return v.to_be_bytes()[(8 - n as usize)..].to_vec();
}
}
let v = (value & ((1u64 << 56) - 1)) | (1u64 << 56);
v.to_be_bytes()[1..].to_vec()
}
/// Minimal big-endian unsigned (EBML uint element body).
fn uint_be(v: u64) -> Vec<u8> {
if v == 0 {
return vec![0u8];
}
let b = v.to_be_bytes();
let first = b.iter().position(|&x| x != 0).unwrap();
b[first..].to_vec()
}
fn elem(id: &[u8], data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(id.len() + 8 + data.len());
out.extend_from_slice(id);
out.extend_from_slice(&vint(data.len() as u64));
out.extend_from_slice(data);
out
}
fn elem_uint(id: &[u8], v: u64) -> Vec<u8> {
elem(id, &uint_be(v))
}
fn ebml_header() -> Vec<u8> {
let mut h = Vec::new();
h.extend(elem_uint(ID_EBMLVERSION, 1));
h.extend(elem_uint(ID_EBMLREADVERSION, 1));
h.extend(elem_uint(ID_EBMLMAXIDLENGTH, 4));
h.extend(elem_uint(ID_EBMLMAXSIZELENGTH, 8));
h.extend(elem(ID_DOCTYPE, b"webm"));
h.extend(elem_uint(ID_DOCTYPEVERSION, 2));
h.extend(elem_uint(ID_DOCTYPEREADVERSION, 2));
elem(ID_EBML, &h)
}
fn info() -> Vec<u8> {
let mut i = Vec::new();
i.extend(elem_uint(ID_TIMECODESCALE, 1_000_000)); // 1 tick = 1 ms
i.extend(elem(ID_MUXINGAPP, b"NetDesk"));
i.extend(elem(ID_WRITINGAPP, b"NetDesk"));
elem(ID_INFO, &i)
}
fn opus_head(channels: u8) -> Vec<u8> {
let mut h = Vec::new();
h.extend_from_slice(b"OpusHead");
h.push(1); // version
h.push(channels.max(1));
h.extend_from_slice(&3840u16.to_le_bytes()); // pre-skip
h.extend_from_slice(&48000u32.to_le_bytes()); // input sample rate
h.extend_from_slice(&0i16.to_le_bytes()); // output gain
h.push(0); // channel-mapping family 0
h
}
fn tracks(width: u16, height: u16, channels: u8) -> Vec<u8> {
let mut v = Vec::new();
v.extend(elem_uint(ID_TRACKNUMBER, 1));
v.extend(elem_uint(ID_TRACKUID, 1));
v.extend(elem_uint(ID_TRACKTYPE, 1)); // video
v.extend(elem(ID_CODECID, b"V_VP9"));
let mut vid = Vec::new();
vid.extend(elem_uint(ID_PIXELWIDTH, width as u64));
vid.extend(elem_uint(ID_PIXELHEIGHT, height as u64));
v.extend(elem(ID_VIDEO, &vid));
let mut entries = elem(ID_TRACKENTRY, &v);
if channels > 0 {
let mut a = Vec::new();
a.extend(elem_uint(ID_TRACKNUMBER, 2));
a.extend(elem_uint(ID_TRACKUID, 2));
a.extend(elem_uint(ID_TRACKTYPE, 2)); // audio
a.extend(elem(ID_CODECID, b"A_OPUS"));
a.extend(elem(ID_CODECPRIVATE, &opus_head(channels)));
let mut aud = Vec::new();
aud.extend(elem(ID_SAMPLINGFREQ, &48000.0f64.to_be_bytes()));
aud.extend(elem_uint(ID_CHANNELS, channels as u64));
a.extend(elem(ID_AUDIO, &aud));
entries.extend(elem(ID_TRACKENTRY, &a));
}
elem(ID_TRACKS, &entries)
}
fn simpleblock(track: u64, rel_tc: i16, keyframe: bool, frame: &[u8]) -> Vec<u8> {
let mut d = Vec::with_capacity(4 + frame.len());
d.extend_from_slice(&vint(track)); // track number (EBML vint, 1-based)
d.extend_from_slice(&rel_tc.to_be_bytes()); // signed 16-bit relative timecode
d.push(if keyframe { 0x80 } else { 0x00 }); // flags: 0x80 = keyframe
d.extend_from_slice(frame);
elem(ID_SIMPLEBLOCK, &d)
}
/// Muxes received VP9 video (+ optional Opus audio) into a `.webm`. No decode /
/// re-encode: the frames are stored exactly as they arrive from the session.
pub struct WebmRecorder {
path: PathBuf,
width: u16,
height: u16,
channels: u8, // 0 = video-only (no audio track)
writer: Option<BufWriter<File>>,
start_ms: Option<u64>,
cluster_base: u64,
cluster: Vec<u8>,
open_cluster: bool,
frames: u64,
}
impl WebmRecorder {
/// `channels` 0 records video only; >0 adds an Opus audio track.
pub fn new(path: impl AsRef<Path>, width: u16, height: u16, channels: u8) -> Self {
Self {
path: path.as_ref().to_path_buf(),
width: width.max(2),
height: height.max(2),
channels,
writer: None,
start_ms: None,
cluster_base: 0,
cluster: Vec::new(),
open_cluster: false,
frames: 0,
}
}
fn ensure_open(&mut self) -> Result<()> {
if self.writer.is_some() {
return Ok(());
}
let mut w = BufWriter::new(File::create(&self.path).map_err(other)?);
w.write_all(&ebml_header()).map_err(other)?;
// Streaming Segment: unknown size (all data bits set in an 8-octet vint).
w.write_all(ID_SEGMENT).map_err(other)?;
w.write_all(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]).map_err(other)?;
w.write_all(&info()).map_err(other)?;
w.write_all(&tracks(self.width, self.height, self.channels)).map_err(other)?;
self.writer = Some(w);
Ok(())
}
fn flush_cluster(&mut self) -> Result<()> {
if self.open_cluster && !self.cluster.is_empty() {
if let Some(w) = self.writer.as_mut() {
let mut body = Vec::with_capacity(self.cluster.len() + 8);
body.extend(elem_uint(ID_TIMECODE, self.cluster_base));
body.extend_from_slice(&self.cluster);
w.write_all(ID_CLUSTER).map_err(other)?;
w.write_all(&vint(body.len() as u64)).map_err(other)?;
w.write_all(&body).map_err(other)?;
}
}
self.cluster.clear();
self.open_cluster = false;
Ok(())
}
fn add_block(&mut self, track: u64, data: &[u8], keyframe: bool, ts_ms: u64) -> Result<()> {
let start = *self.start_ms.get_or_insert(ts_ms);
let rel_total = ts_ms.saturating_sub(start);
// New cluster on a video keyframe (so seeking works) or before the
// relative timecode could overflow the signed-16-bit SimpleBlock field.
if !self.open_cluster
|| (track == 1 && keyframe)
|| rel_total.saturating_sub(self.cluster_base) > 30_000
{
self.flush_cluster()?;
self.cluster_base = rel_total;
self.open_cluster = true;
}
let rel = rel_total.saturating_sub(self.cluster_base).min(32_767) as i16;
self.cluster.extend(simpleblock(track, rel, keyframe, data));
self.frames += 1;
Ok(())
}
/// Feed one VP9 frame. The file opens on the first keyframe.
pub fn push_video(&mut self, data: &[u8], keyframe: bool, ts_ms: u64) -> Result<()> {
if self.writer.is_none() {
if !keyframe {
return Ok(()); // wait for the first keyframe to start the file
}
self.ensure_open()?;
}
if data.is_empty() {
return Ok(());
}
self.add_block(1, data, keyframe, ts_ms)
}
/// Feed one Opus packet (ignored until the video track + file exist, and
/// when the recorder was created video-only).
pub fn push_audio(&mut self, data: &[u8], ts_ms: u64) -> Result<()> {
if self.writer.is_none() || self.channels == 0 || data.is_empty() {
return Ok(());
}
self.add_block(2, data, true, ts_ms) // every Opus frame is independent
}
/// Flush the final cluster and close the file. Returns (path, block count).
pub fn finish(mut self) -> Result<(PathBuf, u64)> {
self.flush_cluster()?;
if let Some(mut w) = self.writer.take() {
w.flush().map_err(other)?;
}
Ok((self.path.clone(), self.frames))
}
pub fn frames(&self) -> u64 {
self.frames
}
}
// ---- verification: a minimal EBML walk that re-reads a muxed file ----
fn find_sub(hay: &[u8], needle: &[u8]) -> bool {
hay.windows(needle.len()).any(|w| w == needle)
}
fn read_id(d: &[u8], p: &mut usize) -> Option<Vec<u8>> {
let b0 = *d.get(*p)?;
let n = (0..4).find(|i| b0 & (0x80 >> i) != 0).map(|i| i + 1)?;
if *p + n > d.len() {
return None;
}
let id = d[*p..*p + n].to_vec();
*p += n;
Some(id)
}
fn read_size(d: &[u8], p: &mut usize) -> Option<u64> {
let b0 = *d.get(*p)?;
let n = (0..8).find(|i| b0 & (0x80 >> i) != 0).map(|i| i + 1)?;
if *p + n > d.len() {
return None;
}
let mut v = (b0 & (0xFF >> n)) as u64;
for i in 1..n {
v = (v << 8) | d[*p + i] as u64;
}
*p += n;
Some(v)
}
/// Re-read a `.webm` and confirm its structure: returns
/// (is_webm_doctype, has_vp9, has_opus, simpleblock_count). Walks the top-level
/// Segment children and descends into each Cluster to count SimpleBlocks for
/// real (no byte-scan false positives for the block count).
pub fn probe_webm(path: impl AsRef<Path>) -> Result<(bool, bool, bool, u64)> {
let data = std::fs::read(path.as_ref()).map_err(other)?;
if data.len() < 4 || &data[0..4] != ID_EBML {
return Err(other("missing EBML magic"));
}
let webm = find_sub(&data, b"webm");
let mut p = 0usize;
// EBML header element (skip).
let _ = read_id(&data, &mut p).ok_or_else(|| other("no EBML id"))?;
let hsz = read_size(&data, &mut p).ok_or_else(|| other("no EBML size"))? as usize;
p += hsz;
// Segment (unknown/huge size) — walk its children to EOF.
let _seg = read_id(&data, &mut p).ok_or_else(|| other("no Segment id"))?;
let _ = read_size(&data, &mut p).ok_or_else(|| other("no Segment size"))?;
let (mut vp9, mut opus, mut blocks) = (false, false, 0u64);
while p < data.len() {
let id = match read_id(&data, &mut p) {
Some(i) => i,
None => break,
};
let sz = match read_size(&data, &mut p) {
Some(s) => s as usize,
None => break,
};
let end = (p + sz).min(data.len());
if id == ID_CLUSTER {
let mut q = p;
while q < end {
let cid = match read_id(&data, &mut q) {
Some(i) => i,
None => break,
};
let csz = match read_size(&data, &mut q) {
Some(s) => s as usize,
None => break,
};
if cid == ID_SIMPLEBLOCK {
blocks += 1;
}
q += csz;
}
} else if id == ID_TRACKS {
let seg = &data[p..end];
vp9 = find_sub(seg, b"V_VP9");
opus = find_sub(seg, b"A_OPUS");
}
p = end;
}
Ok((webm, vp9, opus, blocks))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mux_vp9_opus_webm_roundtrips() {
// Codec-agnostic remux: the payload bytes are arbitrary (the muxer does
// not parse VP9/Opus — the live session supplies real frames). We prove
// the CONTAINER: a valid WebM with a V_VP9 + A_OPUS track and the fed
// frames stored as SimpleBlocks, re-read by probe_webm.
let dir = std::env::temp_dir();
let path = dir.join("netdesk_webm_mux_test.webm");
let _ = std::fs::remove_file(&path);
let mut rec = WebmRecorder::new(&path, 320, 240, 2);
// audio before the first keyframe is dropped (file not open yet)
rec.push_audio(&[0xAA; 40], 0).unwrap();
// first keyframe opens the file
rec.push_video(&[0x11; 300], true, 0).unwrap();
rec.push_audio(&[0xBB; 40], 0).unwrap();
rec.push_video(&[0x22; 120], false, 33).unwrap();
rec.push_audio(&[0xCC; 40], 20).unwrap();
rec.push_video(&[0x33; 110], false, 66).unwrap();
rec.push_video(&[0x44; 280], true, 100).unwrap(); // 2nd keyframe => new cluster
rec.push_audio(&[0xDD; 40], 100).unwrap();
let (out, frames) = rec.finish().unwrap();
assert!(out.exists(), "webm file not written");
let sz = std::fs::metadata(&out).unwrap().len();
let (is_webm, has_vp9, has_opus, blocks) = probe_webm(&out).unwrap();
println!(
"WEBM_OK file={} bytes={} frames={} vp9={} opus={} simpleblocks={}",
out.display(), sz, frames, has_vp9, has_opus, blocks
);
assert!(is_webm, "DocType is not webm");
assert!(has_vp9, "no V_VP9 track");
assert!(has_opus, "no A_OPUS track");
// 4 video + 4 audio pushed, but the 1st audio (pre-keyframe) was dropped.
assert_eq!(blocks, 7, "expected 7 SimpleBlocks, got {blocks}");
assert_eq!(frames, 7, "recorder frame count");
let _ = std::fs::remove_file(&out);
}
#[test]
fn video_only_webm_has_no_audio_track() {
let path = std::env::temp_dir().join("netdesk_webm_vid_only.webm");
let _ = std::fs::remove_file(&path);
let mut rec = WebmRecorder::new(&path, 64, 64, 0);
rec.push_audio(&[0x01; 20], 0).unwrap(); // ignored (video-only)
rec.push_video(&[0x11; 50], true, 0).unwrap();
rec.push_video(&[0x22; 40], false, 33).unwrap();
let (out, frames) = rec.finish().unwrap();
let (is_webm, has_vp9, has_opus, blocks) = probe_webm(&out).unwrap();
assert!(is_webm && has_vp9 && !has_opus, "video-only must have VP9 and no Opus");
assert_eq!((frames, blocks), (2, 2));
let _ = std::fs::remove_file(&out);
}
}
+149
View File
@@ -0,0 +1,149 @@
//! End-to-end: the core HOST (host.rs) serves a session that the core VIEWER
//! (signaling.rs connect) authenticates to, over the LIVE server. Proves the
//! Android host's protocol (register -> challenge -> auth/E2E -> accept) and
//! input routing interoperate before any Kotlin is wired.
//!
//! Network test -> #[ignore] by default. Run explicitly:
//! cargo test -p netdesk-core --target x86_64-pc-windows-gnu --test host_viewer -- --ignored --nocapture
use netdesk_core::crypto::{gen_host_id, new_host_credentials};
use netdesk_core::host::{run_host, HostConfig, HostEvent, HostFrame};
use netdesk_core::{connect, ConnectConfig, SessionEvent};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::timeout;
async fn wait_host<F: Fn(&HostEvent) -> bool>(
rx: &mut mpsc::UnboundedReceiver<HostEvent>,
pred: F,
secs: u64,
) -> Option<HostEvent> {
let deadline = Duration::from_secs(secs);
loop {
match timeout(deadline, rx.recv()).await {
Ok(Some(ev)) => {
if pred(&ev) {
return Some(ev);
}
}
_ => return None,
}
}
}
async fn wait_view<F: Fn(&SessionEvent) -> bool>(
rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
pred: F,
secs: u64,
) -> Option<SessionEvent> {
let deadline = Duration::from_secs(secs);
loop {
match timeout(deadline, rx.recv()).await {
Ok(Some(ev)) => {
if pred(&ev) {
return Some(ev);
}
}
_ => return None,
}
}
}
#[tokio::test]
#[ignore]
async fn host_viewer_handshake_and_input() {
let ws = "wss://netdesk.space/ws";
let id = gen_host_id();
let password = "e2e-host-test-pw";
let (salt_b64, iters, verifier_hex) = new_host_credentials(password);
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let hcfg = HostConfig {
ws_url: ws.into(),
host_id: id.clone(),
name: "TestAndroidHost".into(),
salt_b64,
iters,
verifier_hex,
width: 1080,
height: 1920,
audio: false,
monitors: Vec::new(),
monitor: 0,
perms: Default::default(),
approve: false,
totp_secret: String::new(),
};
// Outbound control channel (unused by this test; drop the sender).
let (_hctl_tx, hctl_rx) = tokio::sync::mpsc::unbounded_channel();
let host_task =
tokio::spawn(async move { run_host(hcfg, frame_rx, hev_tx, hctl_rx).await });
assert!(
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Registered), 10)
.await
.is_some(),
"host did not register"
);
// let the registration settle on the server before the viewer dials in
tokio::time::sleep(Duration::from_millis(600)).await;
// viewer connects to our host id
let (vtx, mut vrx) = mpsc::unbounded_channel::<SessionEvent>();
let vcfg = ConnectConfig {
ws_url: ws.into(),
host_id: id.clone(),
password: password.into(),
totp: String::new(),
takeover: false,
};
let session = connect(vcfg, vtx).await.expect("viewer connect+handshake");
// host sees the viewer; viewer sees Connected with our advertised size
assert!(
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10)
.await
.is_some(),
"host never saw ViewerConnected"
);
let connected = wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 10).await;
match connected {
Some(SessionEvent::Connected { w, h, e2e, .. }) => {
assert_eq!((w, h), (1080, 1920), "advertised resolution");
assert!(e2e, "E2E negotiated");
}
_ => panic!("viewer never got Connected"),
}
// viewer input must surface on the host side with matching coordinates
session.mouse(123, 456, 1, 0.0, 0.0);
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await {
Some(HostEvent::Mouse { x, y, buttons, .. }) => {
assert_eq!((x, y, buttons), (123, 456, 1), "mouse routed to host");
}
_ => panic!("host never received the viewer's mouse"),
}
session.key("KeyA", true);
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Key { .. }), 10).await {
Some(HostEvent::Key { code, down }) => {
assert_eq!((code.as_str(), down), ("KeyA", true), "key routed to host");
}
_ => panic!("host never received the viewer's key"),
}
// a frame fed to the host reaches the viewer as a binary message (we only
// assert the host accepts + seals it; decode is covered elsewhere).
let _ = frame_tx.send(HostFrame {
data: vec![0, 0, 0, 1, 0x67, 0x42, 0, 0x28],
keyframe: true,
codec: 0,
});
tokio::time::sleep(Duration::from_millis(300)).await;
session.close();
drop(frame_tx);
let _ = timeout(Duration::from_secs(3), host_task).await;
println!("HOST_VIEWER_E2E_OK id={id}");
}
File diff suppressed because it is too large. Load diff
+739
View File
@@ -0,0 +1,739 @@
//! Host-enforced permission ACL + view-only + approve-before-connect — protocol
//! tests over the in-memory relay (same harness as p2p_loopback.rs): the FULL
//! `connect_with` / `run_host_with` stacks run server-free, and the assertions
//! are on what the ENGINE emits/sends — i.e. what a (possibly modified) viewer
//! could actually make the host do, not what a well-behaved UI chooses to send.
//!
//! Proves the Tier-0 security fix: with a permission disabled, the engine never
//! emits the corresponding platform event (input/clipboard/file/audio), replies
//! `file.err` to file requests, advertises the REAL perms in `session.accept`,
//! enforces viewer-requested view-only host-side, and holds a password-passed
//! session for Accept/Reject when approve mode is on.
use async_trait::async_trait;
use serde_json::Value;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::timeout;
use netdesk_core::crypto::{
gen_host_id, new_host_credentials, password_proof, password_proof_bound,
};
use netdesk_core::host::{run_host_with, HostConfig, HostControl, HostEvent, HostFrame, HostPerms};
use netdesk_core::signaling::connect_with;
use netdesk_core::{ConnectConfig, NdError, SessionEvent, Transport, TransportMsg};
// ---------------------------------------------------------------------------
// In-memory relay pair (the p2p_loopback.rs harness, minus the drop toggles).
// ---------------------------------------------------------------------------
struct MemTransport {
tx: mpsc::UnboundedSender<TransportMsg>,
rx: mpsc::UnboundedReceiver<TransportMsg>,
}
fn mem_relay_pair() -> (MemTransport, MemTransport) {
let (a_tx, a_rx) = mpsc::unbounded_channel::<TransportMsg>();
let (b_tx, b_rx) = mpsc::unbounded_channel::<TransportMsg>();
(MemTransport { tx: b_tx, rx: a_rx }, MemTransport { tx: a_tx, rx: b_rx })
}
#[async_trait]
impl Transport for MemTransport {
async fn send_binary(&mut self, data: Vec<u8>) -> netdesk_core::Result<()> {
self.tx
.send(TransportMsg::Binary(data))
.map_err(|_| NdError::Connect("mem relay peer gone".into()))
}
async fn send_text(&mut self, text: String) -> netdesk_core::Result<()> {
self.tx
.send(TransportMsg::Text(text))
.map_err(|_| NdError::Connect("mem relay peer gone".into()))
}
async fn recv(&mut self) -> Option<netdesk_core::Result<TransportMsg>> {
self.rx.recv().await.map(Ok)
}
}
async fn wait_host<F: Fn(&HostEvent) -> bool>(
rx: &mut mpsc::UnboundedReceiver<HostEvent>,
pred: F,
secs: u64,
) -> Option<HostEvent> {
let deadline = Duration::from_secs(secs);
loop {
match timeout(deadline, rx.recv()).await {
Ok(Some(ev)) => {
if pred(&ev) {
return Some(ev);
}
}
_ => return None,
}
}
}
async fn wait_view<F: Fn(&SessionEvent) -> bool>(
rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
pred: F,
secs: u64,
) -> Option<SessionEvent> {
let deadline = Duration::from_secs(secs);
loop {
match timeout(deadline, rx.recv()).await {
Ok(Some(ev)) => {
if pred(&ev) {
return Some(ev);
}
}
_ => return None,
}
}
}
fn host_cfg(perms: HostPerms, approve: bool) -> (HostConfig, String) {
host_cfg_totp(perms, approve, "")
}
fn host_cfg_totp(perms: HostPerms, approve: bool, totp_secret: &str) -> (HostConfig, String) {
let password = "perms-test-pw".to_string();
let (salt_b64, iters, verifier_hex) = new_host_credentials(&password);
let cfg = HostConfig {
ws_url: "mem://relay".into(),
host_id: gen_host_id(),
name: "PermsHost".into(),
salt_b64,
iters,
verifier_hex,
width: 1280,
height: 720,
audio: true,
monitors: Vec::new(),
monitor: 0,
perms,
approve,
totp_secret: totp_secret.to_string(),
};
(cfg, password)
}
/// Collect host events until `marker` matches (inclusive), with a timeout.
/// Returns everything seen before the marker.
async fn collect_until_marker<F: Fn(&HostEvent) -> bool>(
rx: &mut mpsc::UnboundedReceiver<HostEvent>,
marker: F,
secs: u64,
) -> Vec<HostEvent> {
let mut seen = Vec::new();
let deadline = Duration::from_secs(secs);
loop {
match timeout(deadline, rx.recv()).await {
Ok(Some(ev)) => {
if marker(&ev) {
return seen;
}
seen.push(ev);
}
_ => panic!("marker event never arrived; saw {seen:?}"),
}
}
}
// ---------------------------------------------------------------------------
// 1) All perms DISABLED: the engine must not emit input/clipboard/file/audio
// events no matter what the viewer sends, and file requests get file.err.
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn perms_disabled_block_all_channels() {
let (viewer_relay, host_relay) = mem_relay_pair();
let perms = HostPerms { input: false, clipboard: false, files: false, audio: false };
let (hcfg, password) = host_cfg(perms, false);
let host_id = hcfg.host_id.clone();
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
let host_task = tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
let (vtx, mut vrx) = mpsc::unbounded_channel::<SessionEvent>();
let vcfg = ConnectConfig {
ws_url: "mem://relay".into(),
host_id,
password,
totp: String::new(),
takeover: false,
};
let session = connect_with(vcfg, vtx, Box::new(viewer_relay))
.await
.expect("viewer connects (password auth still works with perms off)");
assert!(
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10)
.await
.is_some(),
"host saw the viewer"
);
// Fire one of EVERYTHING a hostile viewer could send on disabled channels…
session.mouse(100, 100, 1, 0.0, 0.0);
session.key("F15", true);
session.key("F15", false);
session.clipboard("must-not-reach-the-platform");
session.audio_set(1);
session.list_roots();
// …then flip view-only as an ORDERED MARKER (its handler is independent of
// the four perms, so it must still emit — everything sent before it was
// processed first on the same ordered channel).
session.view_only(true);
let before = collect_until_marker(
&mut hev_rx,
|e| matches!(e, HostEvent::ViewOnly { on: true }),
10,
)
.await;
for ev in &before {
assert!(
!matches!(
ev,
HostEvent::Mouse { .. }
| HostEvent::Key { .. }
| HostEvent::Clipboard { .. }
| HostEvent::File { .. }
| HostEvent::FileChunk { .. }
| HostEvent::AudioSource { .. }
| HostEvent::Voice { .. }
),
"engine leaked a blocked event to the platform: {ev:?}"
);
}
// The denied file request must answer with a clear file.err.
match wait_view(&mut vrx, |e| matches!(e, SessionEvent::FileError { .. }), 10).await {
Some(SessionEvent::FileError { msg }) => {
assert!(
msg.contains("disabled"),
"file.err should say the channel is disabled, got: {msg}"
);
}
_ => panic!("viewer never got file.err for the denied file.roots"),
}
session.close();
drop(frame_tx);
let _ = timeout(Duration::from_secs(5), host_task).await;
println!("PERMS_DENY_OK all four channels blocked at the engine + file.err reply");
}
// ---------------------------------------------------------------------------
// 2) Default perms: every channel flows (no regression from the gates).
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn perms_default_channels_flow() {
let (viewer_relay, host_relay) = mem_relay_pair();
let (hcfg, password) = host_cfg(HostPerms::default(), false);
let host_id = hcfg.host_id.clone();
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
let host_task = tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
let (vtx, _vrx) = mpsc::unbounded_channel::<SessionEvent>();
let vcfg = ConnectConfig {
ws_url: "mem://relay".into(),
host_id,
password,
totp: String::new(),
takeover: false,
};
let session = connect_with(vcfg, vtx, Box::new(viewer_relay)).await.expect("connect");
session.mouse(321, 654, 2, 0.0, 0.0);
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await {
Some(HostEvent::Mouse { x, y, buttons, .. }) => assert_eq!((x, y, buttons), (321, 654, 2)),
_ => panic!("mouse blocked despite perms on"),
}
// Relative (game-mouse) motion flows through the same input gate.
session.mouse_rel(17, -9, 1, 0.0);
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::MouseRel { .. }), 10).await {
Some(HostEvent::MouseRel { dx, dy, buttons, .. }) => {
assert_eq!((dx, dy, buttons), (17, -9, 1))
}
_ => panic!("relative mouse blocked despite perms on"),
}
// Translate-keyboard Unicode text flows through the same input gate.
session.text("h\u{00e9}llo");
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Text { .. }), 10).await {
Some(HostEvent::Text { text }) => assert_eq!(text, "h\u{00e9}llo"),
_ => panic!("text input blocked despite perms on"),
}
// Annotation strokes flow through the same input gate and reach the host.
session.annotate_stroke(0xFF_0000, 3, &[(10, 20), (30, 40)]);
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Annot { .. }), 10).await {
Some(HostEvent::Annot { json }) => assert!(json.contains("stroke"), "annot json: {json}"),
_ => panic!("annotation blocked despite perms on"),
}
session.key("KeyA", true);
assert!(
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Key { down: true, .. }), 10)
.await
.is_some(),
"key blocked despite perms on"
);
session.clipboard("hello");
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Clipboard { .. }), 10).await {
Some(HostEvent::Clipboard { text }) => assert_eq!(text, "hello"),
_ => panic!("clipboard blocked despite perms on"),
}
session.audio_set(1);
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::AudioSource { .. }), 10).await {
Some(HostEvent::AudioSource { src }) => assert_eq!(src, 1),
_ => panic!("audio.set blocked despite perms on"),
}
session.list_roots();
assert!(
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::File { .. }), 10)
.await
.is_some(),
"file.roots blocked despite perms on"
);
session.close();
drop(frame_tx);
let _ = timeout(Duration::from_secs(5), host_task).await;
println!("PERMS_ALLOW_OK all channels flow with default perms");
}
// ---------------------------------------------------------------------------
// 3) View-only: input gated OFF and back ON mid-session, enforced host-side.
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn view_only_gates_input_live() {
let (viewer_relay, host_relay) = mem_relay_pair();
let (hcfg, password) = host_cfg(HostPerms::default(), false);
let host_id = hcfg.host_id.clone();
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
let host_task = tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
let (vtx, _vrx) = mpsc::unbounded_channel::<SessionEvent>();
let vcfg = ConnectConfig {
ws_url: "mem://relay".into(),
host_id,
password,
totp: String::new(),
takeover: false,
};
let session = connect_with(vcfg, vtx, Box::new(viewer_relay)).await.expect("connect");
// Input flows before view-only…
session.mouse(10, 10, 0, 0.0, 0.0);
assert!(
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await.is_some(),
"input should flow before view-only"
);
// …is dropped while it is on…
session.view_only(true);
assert!(
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewOnly { on: true }), 10)
.await
.is_some()
);
session.mouse(20, 20, 1, 0.0, 0.0);
session.key("KeyB", true);
session.view_only(false); // ordered marker: everything above processed first
let between = collect_until_marker(
&mut hev_rx,
|e| matches!(e, HostEvent::ViewOnly { on: false }),
10,
)
.await;
for ev in &between {
assert!(
!matches!(ev, HostEvent::Mouse { .. } | HostEvent::Key { .. }),
"input leaked through host-enforced view-only: {ev:?}"
);
}
// …and flows again after it is off.
session.mouse(30, 30, 0, 0.0, 0.0);
assert!(
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await.is_some(),
"input should flow again after view-only off"
);
session.close();
drop(frame_tx);
let _ = timeout(Duration::from_secs(5), host_task).await;
println!("VIEW_ONLY_OK host-enforced input gate on/off mid-session");
}
// ---------------------------------------------------------------------------
// 4) Wire-level: session.accept advertises the REAL perms; approve mode holds
// the session and Accept/Reject finalizes/denies it. Drives the raw
// protocol by hand so the actual JSON on the wire is asserted.
// ---------------------------------------------------------------------------
/// A parsed session.challenge from the host.
struct Challenge {
sid: String,
salt: String,
iters: u32,
nonce: String,
pub_b64: String,
totp: bool,
}
/// Drive begin → challenge on a raw viewer-end transport; return the challenge.
async fn raw_challenge(viewer: &mut MemTransport, host_id: &str) -> Challenge {
viewer
.send_text(format!(r#"{{"t":"session.begin","id":"{host_id}","peer":"203.0.113.7","user":"tester@example.com"}}"#))
.await
.unwrap();
loop {
match timeout(Duration::from_secs(10), viewer.recv()).await {
Ok(Some(Ok(TransportMsg::Text(t)))) => {
let v: Value = serde_json::from_str(&t).unwrap();
if v["t"] == "session.challenge" {
return Challenge {
sid: v["sid"].as_str().unwrap().to_string(),
salt: v["salt"].as_str().unwrap().to_string(),
iters: v["iters"].as_u64().unwrap() as u32,
nonce: v["nonce"].as_str().unwrap().to_string(),
pub_b64: v["pub"].as_str().unwrap_or("").to_string(),
totp: v["totp"].as_bool().unwrap_or(false),
};
}
}
_ => panic!("no session.challenge"),
}
}
}
/// Drive begin → challenge → auth (plain proof only, legacy viewer). Returns sid.
async fn raw_auth(viewer: &mut MemTransport, host_id: &str, password: &str) -> String {
let ch = raw_challenge(viewer, host_id).await;
let proof = password_proof(password, &ch.salt, &ch.nonce, ch.iters).unwrap();
viewer
.send_text(format!(r#"{{"t":"session.auth","sid":"{}","proof":"{proof}"}}"#, ch.sid))
.await
.unwrap();
ch.sid
}
async fn next_text(viewer: &mut MemTransport, secs: u64) -> Option<Value> {
loop {
match timeout(Duration::from_secs(secs), viewer.recv()).await {
Ok(Some(Ok(TransportMsg::Text(t)))) => {
return serde_json::from_str(&t).ok();
}
Ok(Some(Ok(TransportMsg::Binary(_)))) => continue,
_ => return None,
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn accept_advertises_real_perms() {
let (mut viewer, host_relay) = mem_relay_pair();
let perms = HostPerms { input: true, clipboard: false, files: true, audio: false };
let (hcfg, password) = host_cfg(perms, false);
let host_id = hcfg.host_id.clone();
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, _hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
let _host = tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
raw_auth(&mut viewer, &host_id, &password).await;
let accept = next_text(&mut viewer, 10).await.expect("session.accept");
assert_eq!(accept["t"], "session.accept");
let p = &accept["info"]["perms"];
assert_eq!(p["input"], true, "perms.input must be advertised truthfully");
assert_eq!(p["clipboard"], false, "perms.clipboard must be advertised truthfully");
assert_eq!(p["files"], true, "perms.files must be advertised truthfully");
assert_eq!(p["audio"], false, "perms.audio must be advertised truthfully");
// Disabled audio also clears the audio capability flag.
assert_eq!(accept["info"]["audio"], false);
println!("PERMS_ADVERTISE_OK session.accept carries the enforced perms");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn approve_holds_then_rejects() {
let (mut viewer, host_relay) = mem_relay_pair();
let (hcfg, password) = host_cfg(HostPerms::default(), true);
let host_id = hcfg.host_id.clone();
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
let _host = tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
let sid = raw_auth(&mut viewer, &host_id, &password).await;
// The platform gets the approve request with the relay-reported identity…
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ApproveRequest { .. }), 10).await {
Some(HostEvent::ApproveRequest { sid: s, peer, user }) => {
assert_eq!(s, sid);
assert_eq!(peer, "203.0.113.7");
assert_eq!(user, "tester@example.com");
}
_ => panic!("no ApproveRequest emitted in approve mode"),
}
// …and the wire stays SILENT (no session.accept while held).
assert!(
timeout(Duration::from_millis(900), viewer.recv()).await.is_err(),
"session must be held (no accept) until the platform answers"
);
// Reject → session.deny with a clear reason.
hctl_tx
.send(HostControl::ApproveReply { sid: sid.clone(), accept: false })
.unwrap();
let deny = next_text(&mut viewer, 10).await.expect("session.deny");
assert_eq!(deny["t"], "session.deny");
assert_eq!(deny["sid"], sid.as_str());
assert_eq!(deny["reason"], "not accepted by host");
println!("APPROVE_REJECT_OK held session denied with 'not accepted by host'");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn approve_accept_finalizes_session() {
let (mut viewer, host_relay) = mem_relay_pair();
let (hcfg, password) = host_cfg(HostPerms::default(), true);
let host_id = hcfg.host_id.clone();
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
let _host = tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
let sid = raw_auth(&mut viewer, &host_id, &password).await;
assert!(
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ApproveRequest { .. }), 10)
.await
.is_some()
);
hctl_tx
.send(HostControl::ApproveReply { sid: sid.clone(), accept: true })
.unwrap();
let accept = next_text(&mut viewer, 10).await.expect("session.accept");
assert_eq!(accept["t"], "session.accept");
assert_eq!(accept["sid"], sid.as_str());
assert_eq!(accept["info"]["perms"]["input"], true);
assert!(
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 10)
.await
.is_some(),
"accepting must finalize the session (ViewerConnected)"
);
println!("APPROVE_ACCEPT_OK held session finalized on Accept");
}
// ---------------------------------------------------------------------------
// 5) Device-key binding (MITM hardening): a viewer that binds the host's REAL
// ephemeral pub is accepted; one that binds a SWAPPED pub (what a malicious
// relay would do) is denied "key verification failed". Legacy viewers that
// send no proof_bind still connect (covered by the approve_* tests above).
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn bound_proof_accepts_matching_key() {
// Rebuild host_cfg inline so we keep both host_id and password.
let (mut viewer, host_relay) = mem_relay_pair();
let (hcfg, password) = host_cfg(HostPerms::default(), false);
let host_id = hcfg.host_id.clone();
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, _hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
let ch = raw_challenge(&mut viewer, &host_id).await;
assert!(!ch.pub_b64.is_empty(), "host must offer an ECDH pub (E2E)");
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
// Bind the host's REAL pub (what an honest, un-swapped path yields).
let bind = password_proof_bound(&password, &ch.salt, &ch.nonce, ch.iters, &ch.pub_b64).unwrap();
viewer
.send_text(format!(
r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","proof_bind":"{bind}","pub":"{}"}}"#,
ch.sid, ch.pub_b64
))
.await
.unwrap();
let accept = next_text(&mut viewer, 10).await.expect("reply");
assert_eq!(accept["t"], "session.accept", "matching bound proof must be accepted");
println!("BIND_OK matching channel-bound proof accepted");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn bound_proof_rejects_swapped_key() {
let (mut viewer, host_relay) = mem_relay_pair();
let (hcfg, password) = host_cfg(HostPerms::default(), false);
let host_id = hcfg.host_id.clone();
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, _hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
let ch = raw_challenge(&mut viewer, &host_id).await;
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
// A malicious relay swapped the host's pub: the viewer binds the ATTACKER's
// pub, but the host verifies against its own real pub → mismatch → deny.
// (A valid-looking but different P-256 point, base64 of 65 bytes 0x04||…)
let swapped = {
use netdesk_core::crypto::HostE2e;
HostE2e::new().our_pub_b64 // a different, real ECDH pub
};
assert_ne!(swapped, ch.pub_b64, "the swapped pub must differ from the host's");
let bind = password_proof_bound(&password, &ch.salt, &ch.nonce, ch.iters, &swapped).unwrap();
viewer
.send_text(format!(
r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","proof_bind":"{bind}","pub":"{}"}}"#,
ch.sid, swapped
))
.await
.unwrap();
let deny = next_text(&mut viewer, 10).await.expect("reply");
assert_eq!(deny["t"], "session.deny", "a swapped-key bound proof must be denied");
assert_eq!(deny["reason"], "key verification failed");
println!("BIND_SWAP_OK relay key-swap detected + denied ('key verification failed')");
}
// ---------------------------------------------------------------------------
// 6) TOTP 2FA on the native host: the challenge advertises totp:true, a wrong/
// missing code is denied "bad 2FA code", and a valid code is accepted.
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn totp_required_rejects_missing_and_accepts_valid() {
use netdesk_core::crypto::{password_proof, totp_at};
use std::time::{SystemTime, UNIX_EPOCH};
let secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // RFC 6238 test secret
// --- wrong/missing code is denied ---
{
let (mut viewer, host_relay) = mem_relay_pair();
let (hcfg, password) = host_cfg_totp(HostPerms::default(), false, secret);
let host_id = hcfg.host_id.clone();
let (_ft, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, _hev) = mpsc::unbounded_channel::<HostEvent>();
let (_hc, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
let ch = raw_challenge(&mut viewer, &host_id).await;
assert!(ch.totp, "challenge must advertise totp:true when a secret is set");
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
// Correct password, NO totp code → bad 2FA.
viewer
.send_text(format!(r#"{{"t":"session.auth","sid":"{}","proof":"{proof}"}}"#, ch.sid))
.await
.unwrap();
let deny = next_text(&mut viewer, 10).await.expect("reply");
assert_eq!(deny["t"], "session.deny");
assert_eq!(deny["reason"], "bad 2FA code");
}
// --- a valid, current code is accepted ---
{
let (mut viewer, host_relay) = mem_relay_pair();
let (hcfg, password) = host_cfg_totp(HostPerms::default(), false, secret);
let host_id = hcfg.host_id.clone();
let (_ft, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, _hev) = mpsc::unbounded_channel::<HostEvent>();
let (_hc, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
let ch = raw_challenge(&mut viewer, &host_id).await;
let proof = password_proof(&password, &ch.salt, &ch.nonce, ch.iters).unwrap();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let code = totp_at(secret, now, 30, 6).unwrap();
viewer
.send_text(format!(
r#"{{"t":"session.auth","sid":"{}","proof":"{proof}","totp":"{code}"}}"#,
ch.sid
))
.await
.unwrap();
let accept = next_text(&mut viewer, 10).await.expect("reply");
assert_eq!(accept["t"], "session.accept", "a valid TOTP code must be accepted");
}
println!("TOTP_OK native host advertises + enforces TOTP (deny bad, accept valid)");
}
// ---------------------------------------------------------------------------
// 7) TCP tunnel routing (core): tunnel.open -> HostEvent::TunnelOpen; a raw
// [0xF3][tid][payload] frame (NOT E2E) -> HostEvent::TunnelData ONLY when
// that tid is open; HostControl::TunnelData -> a [0xF3] frame on the wire.
// Proves the server-relayed tunnel plumbing + the 0xF3 disambiguation.
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn tunnel_routing_core() {
let (mut viewer, host_relay) = mem_relay_pair();
let (hcfg, _password) = host_cfg(HostPerms::default(), false);
let (_ft, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
// The server asks the host to open tunnel tid=7 (no video session needed).
viewer
.send_text(r#"{"t":"tunnel.open","tid":7,"thost":"127.0.0.1","port":5555}"#.into())
.await
.unwrap();
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::TunnelOpen { .. }), 10).await {
Some(HostEvent::TunnelOpen { tid, thost, port }) => {
assert_eq!((tid, thost.as_str(), port), (7, "127.0.0.1", 5555));
}
_ => panic!("no TunnelOpen"),
}
// Raw [0xF3][tid=7 LE][payload] -> TunnelData for an OPEN tid.
let mut frame = vec![0xF3u8];
frame.extend_from_slice(&7u32.to_le_bytes());
frame.extend_from_slice(b"hello-tcp");
viewer.send_binary(frame).await.unwrap();
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::TunnelData { .. }), 10).await {
Some(HostEvent::TunnelData { tid, data }) => {
assert_eq!(tid, 7);
assert_eq!(&data, b"hello-tcp");
}
_ => panic!("no TunnelData for an open tid"),
}
// Outbound: HostControl::TunnelData -> a raw [0xF3][tid][data] on the wire.
hctl_tx
.send(HostControl::TunnelData { tid: 7, data: b"reply42".to_vec() })
.unwrap();
loop {
match timeout(Duration::from_secs(10), viewer.recv()).await {
Ok(Some(Ok(TransportMsg::Binary(b)))) => {
assert_eq!(b[0], 0xF3);
assert_eq!(u32::from_le_bytes([b[1], b[2], b[3], b[4]]), 7);
assert_eq!(&b[5..], b"reply42");
break;
}
Ok(Some(Ok(TransportMsg::Text(_)))) => continue,
_ => panic!("no outbound tunnel frame"),
}
}
println!("TUNNEL_CORE_OK tunnel.open + [0xF3] route in/out, tid-gated");
}
+180
View File
@@ -0,0 +1,180 @@
# Building NetDesk for Android
NetDesk's Android app is a Flutter UI (`ui/`) bound, through
[flutter_rust_bridge](https://cjycode.com/flutter_rust_bridge/) (FRB v2), to a
compiled Rust library. The Rust FFI layer is the **`netdesk-frb`** crate
(`frb/`), a `cdylib` that statically links **`netdesk-core`** (signaling, E2E
crypto, transport, H.264 decode). On Android the library ships inside the
APK/AAB as `lib/<abi>/libnetdesk_frb.so` and the Dart side `dlopen`s it (the FRB
loader looks for the stem `netdesk_frb`).
```
netdesk-frb (+ netdesk-core) --cargo-ndk--> ui/android/app/src/main/jniLibs/<abi>/libnetdesk_frb.so
Flutter (ui/) --gradle-----> release .apk / split .apk / .aab (signed)
```
---
## The one command
From the repo root (`netdesk-native/`), in a shell whose `PATH` contains the
MinGW tools, cargo, and Flutter:
```powershell
powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1
```
That single command:
1. verifies / auto-provisions the toolchain (cargo-ndk, the rustup Android
targets),
2. detects the Android NDK at `C:\dev\android-ndk`,
3. compiles `netdesk-frb` for **arm64-v8a, armeabi-v7a, x86_64** with cargo-ndk
and drops `libnetdesk_frb.so` straight into
`ui/android/app/src/main/jniLibs/<abi>/`,
4. builds the release **fat APK**, the **per-ABI split APKs**, and the Play
**App Bundle (.aab)**.
Useful switches:
| Switch | Effect |
| --- | --- |
| `-Target apk\|split\|aab\|rust\|all` | Build only that deliverable (default `all`; `rust` = just the `.so`). |
| `-SkipRust` | Reuse the `.so` already in `jniLibs`, build Flutter only. |
| `-Abis arm64-v8a` | Restrict the ABIs (default all three). |
| `-GenerateKeystore` | Create the release keystore + `key.properties` first (see below). |
| `-MinSdk 21` | Android API the `.so` links against (must be `<=` Flutter `minSdk`). |
| `-NdkHome <path>` | Override the NDK location (default `C:\dev\android-ndk`). |
---
## Toolchain
| Component | Version / location used here |
| --- | --- |
| Rust | stable (cargo 1.99) on `PATH` |
| rustup targets | `aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android` |
| cargo-ndk | 4.1.2 (`cargo install cargo-ndk`) |
| Android NDK | r26d (`26.3.11579264`) at `C:\dev\android-ndk` (`ANDROID_NDK_HOME`) |
| Android SDK | `%LOCALAPPDATA%\Android\Sdk` |
| Flutter | `C:\dev\flutter\bin\flutter.bat` |
| JDK (keytool + Gradle) | Android Studio JBR (auto-detected) |
| nasm | on `PATH` (needed by `openh264` for the x86_64 ABI) |
One-time setup (the build script also does the first three automatically):
```powershell
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
cargo install cargo-ndk
# NDK: unzip the Android NDK r26+ to C:\dev\android-ndk (set ANDROID_NDK_HOME)
```
Recommended `PATH` (Git Bash form):
```bash
export PATH="/c/mingw-netdesk/mingw64/bin:$HOME/.cargo/bin:/c/dev/flutter/bin:$PATH"
```
### What cargo-ndk runs
The Rust step is exactly:
```
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \
-o ui/android/app/src/main/jniLibs --platform 21 \
build --release --package netdesk-frb --lib
```
cargo-ndk sets the per-ABI clang/linker from the NDK, and the `-o` flag copies
each produced `libnetdesk_frb.so` into `jniLibs/<abi>/`. The `.so` are build
artifacts (git-ignored) and are regenerated on every run.
---
## Release signing
Signing credentials live in `ui/android/key.properties`, which is **git-ignored**
(so are `*.keystore` / `*.jks`). A committable template is
`ui/android/key.properties.example`.
`app/build.gradle.kts` loads `key.properties` when present and signs the release
build with it; when it is absent the release build falls back to **debug**
signing so `flutter run --release` still works during development.
Create the upload keystore once (the password is read from the environment and
is **never** written into a committed file):
```powershell
$env:NETDESK_KEYSTORE_PASSWORD = '<your-strong-password>'
powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -GenerateKeystore
```
This writes:
* `ui/android/app/netdesk-release.keystore` - RSA 4096, alias `netdesk`, 10000-day validity,
* `ui/android/key.properties` - with the alias, store file, and the password you
supplied (local only; keep it out of version control).
> Back up the keystore and remember the password. If you lose either you can no
> longer push updates to an app already published under that key (unless it uses
> Play App Signing with a separate upload key you can reset).
---
## Outputs
After a full build:
| Deliverable | Path (under `ui/`) |
| --- | --- |
| Fat APK (all ABIs) | `build/app/outputs/flutter-apk/app-release.apk` |
| Split APKs (per ABI) | `build/app/outputs/flutter-apk/app-arm64-v8a-release.apk`, `-armeabi-v7a-`, `-x86_64-` |
| Play App Bundle | `build/app/outputs/bundle/release/app-release.aab` |
The fat APK is convenient for side-loading / direct download; the split APKs are
smaller per device; the `.aab` is what Google Play requires.
---
## Publishing to Google Play (AAB)
1. Produce the signed bundle:
```powershell
powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -Target aab
```
(or run the default `all` target). The bundle is
`ui/build/app/outputs/bundle/release/app-release.aab`.
2. In the [Play Console](https://play.google.com/console), create the app, then
upload the `.aab` under a release track (internal testing first).
3. Enrol in **Play App Signing** (recommended). Google then holds the *app
signing key*; the keystore above becomes your *upload key*. If the upload key
is ever compromised you can ask Google to reset it - which is why App Signing
is preferred over shipping the raw signing key.
4. `versionCode` / `versionName` come from `ui/pubspec.yaml`
(`version: x.y.z+build`). Bump the `+build` number for every Play upload - the
Console rejects a duplicate `versionCode`.
---
## Troubleshooting
* **"Android NDK not found"** - the script could not find a complete NDK at
`C:\dev\android-ndk` (it checks for `source.properties` +
`toolchains\llvm\prebuilt`). Finish extracting the NDK, set `ANDROID_NDK_HOME`,
or pass `-NdkHome <path>`. Use `-SkipRust` to build only the Flutter layer
against an already-built `.so`.
* **x86_64 ABI fails in `openh264`** - that ABI needs `nasm` on `PATH`. arm64-v8a
and armeabi-v7a do not. Drop x86_64 with `-Abis arm64-v8a,armeabi-v7a` if you
only ship to real devices.
* **Gradle wants to download an NDK** - `app/build.gradle.kts` points AGP at
`ANDROID_NDK_HOME` when it is set, so the prebuilt (already stripped) `.so` are
packaged without a second NDK download. Make sure the env var is set (the build
script sets it).
* **`UnsatisfiedLinkError: libnetdesk_frb.so`** at runtime - the `.so` for the
device's ABI is missing from `jniLibs`. Re-run the Rust step (`-Target rust`)
for that ABI, or confirm the FRB loader stem is still `netdesk_frb`.
+288
View File
@@ -0,0 +1,288 @@
# NetDesk — iOS build (`.ipa`)
How to build the signed NetDesk iOS app — the Flutter UI on top of the compiled
Rust `netdesk-core` engine — and ship it to TestFlight / the App Store.
> **This only builds on a Mac.** iOS toolchains (Xcode, the iOS SDKs,
> `xcodebuild`, code signing) exist on macOS only. Nothing here can run on the
> Windows dev box — Windows is used to *author* these scripts, the Mac runs
> them. Everything below assumes macOS + Xcode.
---
## 1. What gets built
```
core crate (netdesk-core, lib netdesk_core)
│ cargo build --release --target aarch64-apple-ios → libnetdesk_core.a (device)
│ cargo build --release --target aarch64-apple-ios-sim → libnetdesk_core.a (simulator)
▼
NetDeskCore.xcframework (xcodebuild -create-xcframework, both slices)
│ vendored via ui/ios/netdesk_core.podspec + pod 'netdesk_core' in the Podfile
▼
Flutter iOS Runner ── flutter build ipa ──► ui/build/ios/ipa/netdesk.ipa
```
The Rust static library is the FFI surface the Flutter UI binds to. Today that
is the workspace **`core`** crate (`crate-type = ["lib","staticlib","cdylib"]`,
lib name `netdesk_core` → `libnetdesk_core.a`). When the Phase-3
`flutter_rust_bridge` wrapper crate lands under a different package name (e.g.
`netdesk-frb`), build it with the same script by overriding two variables:
```bash
CORE_CRATE=netdesk-frb LIB_NAME=netdesk_frb packaging/build-ios.sh --team <TEAM_ID>
```
---
## 2. Prerequisites
### 2.1 Apple Developer Program — **$99/year**
A paid **Apple Developer Program** membership (<https://developer.apple.com/programs/>,
US$99/yr) is required to:
- create a **distribution signing certificate**,
- register an **App ID** (bundle identifier) and a **provisioning profile**,
- upload to **App Store Connect / TestFlight**.
The free personal team can only sideload to your own device for 7 days — not
enough to ship. Enroll first; enrollment can take a day or two to clear.
### 2.2 Signing certificate + provisioning profile
You need, inside your developer account:
| Thing | Where | Notes |
|-------|-------|-------|
| **Team ID** | developer.apple.com → Membership | 10 chars, e.g. `ABCDE12345`. You pass this to the script. |
| **App ID / bundle id** | Certificates, IDs & Profiles → Identifiers | Must match the app's `PRODUCT_BUNDLE_IDENTIFIER` (default `space.netdesk.netdesk`, set via `--org`). |
| **Distribution certificate** | Certificates, IDs & Profiles → Certificates → *Apple Distribution* | One per team; Xcode can create it for you. |
| **Provisioning profile** | Certificates, IDs & Profiles → Profiles → *App Store* (or Ad Hoc) | Ties the App ID + certificate + devices. |
**Easiest path — automatic signing.** Open the project once in Xcode, sign in
with your Apple ID, tick *Automatically manage signing*, pick your Team. Xcode
creates the certificate + profile for you and the command-line build reuses
them. `build-ios.sh` writes `DEVELOPMENT_TEAM` + `CODE_SIGN_STYLE = Automatic`
into `ui/ios/Flutter/{Release,Debug}.xcconfig`, so after that one-time Xcode
sign-in the whole build is non-interactive.
**Manual signing** (CI, locked-down teams): download the distribution
certificate (`.p12`) and the provisioning profile (`.mobileprovision`), install
both into the login keychain / `~/Library/MobileDevice/Provisioning Profiles/`,
then set `signingStyle` to `manual` and add a `provisioningProfiles` dict in
`ui/ios/ExportOptions.plist`.
### 2.3 Toolchains on the Mac
```bash
# Xcode + command line tools
xcode-select --install # or install full Xcode from the App Store
sudo xcode-select -s /Applications/Xcode.app
# Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
# Flutter SDK (>= 3.27, per ui/pubspec.yaml) — add its bin/ to PATH
# https://docs.flutter.dev/get-started/install/macos
flutter doctor
# CocoaPods (Flutter will also install pods during the build)
sudo gem install cocoapods
```
> **PATH note.** On Windows the project builds with
> `export PATH="/c/mingw-netdesk/mingw64/bin:$HOME/.cargo/bin:/c/dev/flutter/bin:$PATH"`.
> On the **Mac** that line does not apply — just make sure `cargo`, `rustup`,
> `flutter` and `xcrun` are on PATH (`source $HOME/.cargo/env`, Flutter's
> `bin/` in your shell profile).
---
## 3. Generate the Flutter iOS project (one time)
`ui/` currently has `lib/` + `pubspec.yaml` but **no `ios/` folder** — it was
never generated (it's platform scaffolding, not source). Create it on the Mac:
```bash
cd ui
flutter create --org space.netdesk --project-name netdesk --platforms=ios .
```
This adds `ui/ios/` (the `Runner` Xcode project, `Runner.xcworkspace`,
`Info.plist`, launch assets, the Flutter xcconfig layer) without touching your
`lib/` Dart code. The bundle identifier becomes `<org>.netdesk`, i.e.
`space.netdesk.netdesk` — change `--org` to use your own (e.g.
`--org com.akronlogic` → `com.akronlogic.netdesk`), and register that exact id
as the App ID in your developer account.
> On the Windows box the equivalent invocation (for reference only — it does not
> produce a buildable iOS app on Windows) is:
> `C:\dev\flutter\bin\flutter.bat create --platforms=ios .`
**`build-ios.sh` runs this step for you** if `ui/ios/` does not exist, so you
can skip straight to §5. Run it by hand only if you want to pick the `--org`
yourself before the first build.
---
## 4. The VideoToolbox decode path (why the frameworks matter)
NetDesk's iOS H.264 decode is **hardware** via **VideoToolbox**, implemented in
[`core/src/decode_vt.rs`](../core/src/decode_vt.rs) and gated
`#[cfg(any(target_os = "macos", target_os = "ios"))]`. It feeds Annex-B →
AVCC-repacked access units into a `VTDecompressionSession`, builds the
`CMVideoFormatDescription` from the first keyframe's SPS/PPS, and asks for
32-bit BGRA output (VideoToolbox does the YUV→RGB on the GPU); openh264 is the
software fallback if the session can't start.
That module links three Apple system frameworks directly with
`#[link(kind = "framework")]`:
| Framework | Used for |
|-----------|----------|
| **VideoToolbox** | `VTDecompressionSession*` (the decoder itself) |
| **CoreMedia** | `CMVideoFormatDescription` / `CMSampleBuffer` / `CMBlockBuffer` |
| **CoreVideo** | `CVPixelBuffer*` (the decoded BGRA surface) |
| **CoreFoundation** | `CFDictionary`/`CFString`/`CFRelease` (via `core-foundation-sys`, for the pixel-buffer attributes) |
A **static** Rust library records those `#[link]` directives in its metadata but
does **not** pull the frameworks into the final app link. So the podspec
(`ui/ios/netdesk_core.podspec`) names them explicitly:
```ruby
s.frameworks = 'VideoToolbox', 'CoreMedia', 'CoreVideo', 'CoreFoundation'
```
If you ever see link errors like `Undefined symbol: _VTDecompressionSessionCreate`
or `_CMVideoFormatDescriptionCreateFromH264ParameterSets`, a framework is
missing from that line. At runtime, the log line
`decode: VideoToolbox H.264 (hardware)` confirms the hardware path was selected.
---
## 5. Build the `.ipa` — the exact commands
### One command (recommended)
```bash
# from the repo root, on the Mac:
packaging/build-ios.sh --team ABCDE12345
```
Replace `ABCDE12345` with your 10-character Apple **Team ID**. That script does
every step in §6 and prints the path to the finished `.ipa`. Useful variants:
```bash
packaging/build-ios.sh --team ABCDE12345 --method app-store # default: App Store / TestFlight
packaging/build-ios.sh --team ABCDE12345 --method ad-hoc # ad-hoc distribution
packaging/build-ios.sh --team ABCDE12345 --method development # dev devices
packaging/build-ios.sh --team ABCDE12345 --org com.akronlogic # bundle id com.akronlogic.netdesk (first run only)
packaging/build-ios.sh --skip-ipa # build NetDeskCore.xcframework only, no signing needed
```
Output:
- `ui/build/ios/ipa/netdesk.ipa` — the signed app
- `packaging/dist/ios/NetDeskCore.xcframework` — the Rust core (also staged into `ui/ios/`)
### The same steps by hand
If you'd rather run it step by step (this is exactly what the script does):
```bash
# 0. Rust iOS targets
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
# 1. Build the Rust core static lib for device + simulator
export IPHONEOS_DEPLOYMENT_TARGET=13.0
cargo build --release -p netdesk-core --target aarch64-apple-ios
cargo build --release -p netdesk-core --target aarch64-apple-ios-sim
# 2. Assemble the xcframework (delete any stale one first)
rm -rf packaging/dist/ios/NetDeskCore.xcframework
xcodebuild -create-xcframework \
-library target/aarch64-apple-ios/release/libnetdesk_core.a \
-library target/aarch64-apple-ios-sim/release/libnetdesk_core.a \
-output packaging/dist/ios/NetDeskCore.xcframework
# 3. Generate ui/ios if needed, then wire the xcframework in
cd ui
flutter create --org space.netdesk --project-name netdesk --platforms=ios . # only if ui/ios is missing
flutter pub get
cp -R ../packaging/dist/ios/NetDeskCore.xcframework ios/
cp ../packaging/ios/netdesk_core.podspec ios/netdesk_core.podspec
# ... add `pod 'netdesk_core', :path => '.'` to ios/Podfile (Runner target),
# ... add DEVELOPMENT_TEAM + CODE_SIGN_STYLE=Automatic to ios/Flutter/*.xcconfig,
# ... write ios/ExportOptions.plist with your teamID + method.
cd ios && pod install && cd ..
# 4. Build + sign the ipa
flutter build ipa --release --export-options-plist=ios/ExportOptions.plist
# → ui/build/ios/ipa/netdesk.ipa
```
### Ship it
```bash
# TestFlight / App Store (App Store Connect API key):
xcrun altool --upload-app -f ui/build/ios/ipa/netdesk.ipa -t ios \
--apiKey <KEY_ID> --apiIssuer <ISSUER_ID>
```
Or open `ui/build/ios/archive/Runner.xcarchive` in **Xcode → Window →
Organizer → Distribute App** and follow the GUI.
---
## 6. What `build-ios.sh` automates (summary)
1. **Preflight** — macOS, Xcode, `rustup`/`cargo`, `flutter`, and a Team ID (unless `--skip-ipa`).
2. **`rustup target add`** `aarch64-apple-ios aarch64-apple-ios-sim`.
3. **`cargo build --release`** the core staticlib for both targets (`IPHONEOS_DEPLOYMENT_TARGET=13.0`).
4. **`xcodebuild -create-xcframework`** → `NetDeskCore.xcframework`.
5. **`flutter create`** `ui/ios` if missing, copy the xcframework + podspec in, inject the pod into the Podfile, set the signing team in the Flutter xcconfigs, write `ExportOptions.plist`, `pod install`.
6. **`flutter build ipa`** with your team and export method.
---
## 7. Gotchas
- **Apple-silicon vs Intel Mac.** The simulator slice defaults to
`aarch64-apple-ios-sim` (Apple silicon). On an Intel Mac build the Intel
simulator slice instead: `SIM_TARGET=x86_64-apple-ios packaging/build-ios.sh …`
(and `rustup target add x86_64-apple-ios`). The device slice is always
`aarch64-apple-ios`.
- **First build compiles openh264 from source** (the software-decode fallback).
That needs the Xcode clang/C++ toolchain (present with Xcode) — the first
`cargo build` is slow; later ones are cached.
- **Deployment target.** Keep `IPHONEOS_DEPLOYMENT_TARGET`, the podspec
`platform :ios`, and the Runner's min iOS in sync (all `13.0` here). Mismatches
cause CocoaPods warnings or link errors.
- **Bundle id must match the App ID.** The provisioning profile is tied to a
specific bundle id; if `--org` doesn't match a registered App ID, signing
fails. Register the id in your developer account first.
- **Phase-3 FFI.** This build links the Rust core but the Flutter UI does not
*call* it yet (the Dart bindings are Phase 3: uncomment `flutter_rust_bridge`
+ `ffi` in `ui/pubspec.yaml`, add `core/src/ffi.rs`, generate the header with
`cbindgen`, add `-headers` to the `xcodebuild -create-xcframework` call, and
run `flutter_rust_bridge_codegen generate`). Until then the archive may be
dead-stripped out of the app — harmless; the signed `.ipa` still builds. See
the commented `-force_load` block in `netdesk_core.podspec` to force a link
test before Phase 3.
- **`xcodebuild -create-xcframework` refuses to overwrite** an existing output —
the script `rm -rf`s the old xcframework first; do the same by hand.
---
## 8. Files
| Path | Role |
|------|------|
| `packaging/build-ios.sh` | the end-to-end Mac build script |
| `packaging/ios/netdesk_core.podspec` | CocoaPods spec vendoring the xcframework + system frameworks (copied to `ui/ios/`) |
| `packaging/ios/Podfile` | Flutter iOS Podfile template with the `netdesk_core` pod (used if `ui/ios/Podfile` is absent) |
| `packaging/dist/ios/NetDeskCore.xcframework` | build output — the compiled Rust core |
| `core/src/decode_vt.rs` | the VideoToolbox hardware-decode implementation |
| `docs/cross-platform-decode.md` | the per-platform decode design (VideoToolbox section) |
+189
View File
@@ -0,0 +1,189 @@
# NetDesk — Linux build & packaging
Build the native `netdesk-viewer` for Linux and bundle the two distributions
**NetDesk by Akronlogic** ships on Linux:
| Edition | Artifact | Install? |
|--------------|-----------------------------------|----------------------------------------|
| **AppImage** | `NetDesk-<ver>-x86_64.AppImage` | No — `chmod +x` and run anywhere |
| **.deb** | `netdesk_<ver>_amd64.deb` | Yes — `apt install ./…` (launcher + icon) |
Both are produced by [`packaging/build-linux.sh`](../packaging/build-linux.sh).
Everything it writes lands in **`packaging/dist/linux/`** (git-ignored). This is
the Linux counterpart of the Windows [`packaging/README.md`](../packaging/README.md).
> This target must be **built on Linux** (x86_64). The viewer links the system
> FFmpeg/VAAPI libraries for the Linux hardware-decode path
> (`core/src/decode_vaapi.rs`), which are not present when cross-building from
> Windows or macOS.
---
## 1. Build dependencies
The Linux decode backend pulls in FFmpeg through the `ffmpeg-next` crate, whose
build script (`ffmpeg-sys-next`) locates `libav*` with **pkg-config** and
generates bindings with **libclang**. The GUI (eframe/egui on winit + glow)
needs OpenGL at runtime. Install the toolchain + dev libraries first.
### Debian / Ubuntu
```bash
sudo apt update
sudo apt install -y build-essential clang pkg-config nasm \
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \
libavfilter-dev libavdevice-dev libswresample-dev \
libva-dev \
libgl1-mesa-dev
```
- `build-essential` → the C/C++ linker (`cc`/`g++`) Rust and `openh264-sys2` need.
- `clang` → libclang for `ffmpeg-sys-next`'s bindgen.
- `pkg-config` → locates the FFmpeg and VAAPI libraries.
- `nasm` → assembler for the bundled `openh264` software-decode fallback.
- `libav*-dev` → FFmpeg ≥ 4 development libraries (the full set matches the
default features of `ffmpeg-next`).
- `libva-dev` → VAAPI headers for the hardware-decode path.
- `libgl1-mesa-dev` → OpenGL for the glow renderer.
### Fedora (FFmpeg dev libs come from RPM Fusion)
```bash
sudo dnf install -y gcc gcc-c++ clang pkgconf-pkg-config nasm \
ffmpeg-devel libva-devel mesa-libGL-devel
```
### Optional — native file-picker
The viewer's **File transfer → Upload…** button uses `rfd`, which is compiled
with **no Linux backend** in `viewer/Cargo.toml` (`default-features = false`).
The build and the app run fine without it, but the picker is a no-op on Linux
until `rfd` is given a backend feature:
- **GTK 3** backend → add `features = ["gtk3"]` to `rfd`, then install
`libgtk-3-dev` (build) / `libgtk-3-0` (runtime).
- **XDG portal** backend (no GTK) → add `features = ["xdg-portal", "tokio"]`;
needs a running `xdg-desktop-portal` at runtime, nothing extra at build.
This is the only reason `libgtk` is ever involved on Linux — it is **not**
required to build or run NetDesk as shipped.
---
## 2. The one command
```bash
export PATH="$HOME/.cargo/bin:$PATH" # ensure cargo/rustup are reachable
packaging/build-linux.sh
```
That runs `cargo build --release -p netdesk-viewer`
(target `x86_64-unknown-linux-gnu`) and then produces **both** the `.deb` and
the AppImage in `packaging/dist/linux/`:
```
packaging/dist/linux/
├── netdesk_0.1.0_amd64.deb ← dpkg package (launcher + hicolor icons)
├── NetDesk-0.1.0-x86_64.AppImage ← portable single file
├── com.akronlogic.netdesk.desktop (generated launcher, shared by both)
├── AppDir/ (AppImage staging)
└── deb/ (.deb staging tree)
```
> The underlying build command on its own:
> ```bash
> cargo build --release -p netdesk-viewer --target x86_64-unknown-linux-gnu
> ```
### Flags
| Flag | Effect |
|-----------------------|----------------------------------------------------|
| `--skip-build` | reuse an existing `target/.../release/netdesk-viewer` |
| `--no-appimage` | build the `.deb` only |
| `--no-deb` | build the AppImage only |
| `--target <triple>` | override the Rust target (default `x86_64-unknown-linux-gnu`) |
| `LINUXDEPLOY=/path` | env: use a local `linuxdeploy` instead of downloading it |
The AppImage step downloads **linuxdeploy** once (cached in
`packaging/dist/.tools/`) and runs it with `APPIMAGE_EXTRACT_AND_RUN=1`, so it
works in containers/CI without FUSE. linuxdeploy `ldd`-scans the binary and
bundles its shared libraries (`libavcodec`, `libavutil`, `libswscale`, …) into
the AppImage; `libGL`, `libX11`/`xcb`, Wayland and `libva` are intentionally
**not** bundled — they are `dlopen()`ed and must come from the host graphics
stack. The `.deb` instead declares those as package `Depends:`.
---
## 3. VAAPI hardware-decode note
NetDesk hardware-decodes the host's H.264 stream on Linux through
**VAAPI via FFmpeg/libavcodec** (`core/src/decode_vaapi.rs`): it opens the
libavcodec H.264 decoder with a VAAPI `hw_device_ctx` on the default DRI render
node (`/dev/dri/renderD128`), decodes on the GPU, pulls the surface to system
memory (NV12), crops to the true display size, and swscales to the packed RGB8
`Frame` the viewer paints — see [`cross-platform-decode.md`](cross-platform-decode.md)
for the full backend matrix.
**Hardware decode is optional at runtime.** `best_decoder()` tries VAAPI first
and **falls back to the openh264 software decoder** if no usable VAAPI device is
present, so headless / GPU-less boxes still work. VAAPI is a build dependency
(the libraries must link) but not a runtime requirement.
To get the hardware path at runtime on the target machine:
1. **Install a VAAPI driver** (the `.deb` lists these as `Recommends`):
- Intel (Broadwell+): `intel-media-va-driver` (iHD)
- Intel (older): `i965-va-driver`
- AMD / others (Mesa): `mesa-va-drivers` (radeonsi)
2. **Verify it:** `vainfo` should list an `H264` decode profile (e.g.
`VAProfileH264High : VAEntrypointVLD`). Install `vainfo` from `libva-utils`.
3. **Render-node access:** the user must be able to read
`/dev/dri/renderD128` — typically by membership of the `render` (or `video`)
group.
4. **Driver override (if needed):** `export LIBVA_DRIVER_NAME=iHD` (or `i965`,
`radeonsi`) when autodetection picks the wrong driver.
Confirm which path is live from the viewer's log:
```
decode: VAAPI H.264 (FFmpeg hardware) ← hardware path selected
VAAPI init failed (...); using openh264 ... ← fell back to software
```
If it fell back, check `vainfo`, the `/dev/dri/renderD128` permissions, and
`LIBVA_DRIVER_NAME`.
---
## 4. Troubleshooting
- **`libavcodec` not found via pkg-config / bindgen errors** — the FFmpeg dev
libraries or `clang` are missing. Re-run the install command in §1. For FFmpeg
in a non-standard prefix, set `PKG_CONFIG_PATH`.
- **`openh264` build fails for want of an assembler** — install `nasm`.
- **AppImage not produced** (linuxdeploy download/FUSE error) — the script warns
and still writes the `.deb`. Supply a local tool with `LINUXDEPLOY=/path`, or
run with `--no-appimage`. `APPIMAGE_EXTRACT_AND_RUN=1` is already exported for
FUSE-less hosts.
- **`.deb` won't install — unmet FFmpeg dependency** — soname package names
differ per release; the control file lists `libavcodec60 | 59 | 58 | ffmpeg`
(and likewise for `libavutil`/`libswscale`). On an unusual release, installing
the distro `ffmpeg` package satisfies the catch-all.
- **Wrong window/taskbar icon on GNOME/Wayland** — the launcher file name and
its `Icon=` key both use the app-id `com.akronlogic.netdesk`, matching the
viewer's `with_app_id(...)`; for the `.deb`, `gtk-update-icon-cache` runs in
`postinst`. Log out/in if the shell cached the old icon.
---
## 5. Test
```bash
vainfo # confirm a VAAPI driver + H264 profile (optional)
cargo test -p netdesk-core # core decode/transport tests
./packaging/dist/linux/NetDesk-*.AppImage # smoke-test the viewer
```
Smoke-test end to end against a live host; the log line
`decode: VAAPI H.264 (FFmpeg hardware)` confirms the hardware path was selected.
+293
View File
@@ -0,0 +1,293 @@
# Building the NetDesk desktop app for macOS
This builds the **same** egui viewer/host-panel binary the Windows editions
ship (crate `netdesk-viewer`) as a signed, notarized **universal** macOS app
(`NetDesk.app`) and a distributable `.dmg`.
- One binary, two roles: the viewer window **and** the host panel, identical to
the Windows build — only the packaging here is Mac-specific.
- **Decode path:** on macOS the host H.264 (Annex-B) stream is decoded on the
Apple video engine through **VideoToolbox** — `core/src/decode_vt.rs`
(`VTDecompressionSession`, 32-bit BGRA output repacked to RGB8), gated
`#[cfg(any(target_os = "macos", target_os = "ios"))]`. It falls back to the
portable openh264 software decoder if the hardware path can't initialize. See
[`cross-platform-decode.md`](./cross-platform-decode.md). No VideoToolbox-
specific flags are needed at build time: the CoreMedia / CoreVideo /
VideoToolbox framework `#[link]`s resolve against the active SDK.
> **Where this runs:** on the Mac (the M4), **not** the Windows dev box. Cross-
> compiling a macOS `.app` from Windows is not supported — codesign, notarytool,
> iconutil, lipo and hdiutil are all macOS-only tools.
The script is [`packaging/build-macos.sh`](../packaging/build-macos.sh). All
outputs land in `packaging/dist/`.
---
## TL;DR — the Mac build command
Once the one-time Apple Developer setup below is done (Developer ID cert in the
keychain + a stored `notarytool` profile named `netdesk`), the whole signed +
notarized build is:
```bash
# on the Mac, from the repo root:
export PATH="$HOME/.cargo/bin:$PATH"
bash packaging/build-macos.sh --notary-profile netdesk
```
That produces, in `packaging/dist/`:
| Artifact | What it is |
|----------------------------------------|---------------------------------------------|
| `NetDesk.app` | signed + notarized + **stapled** universal app |
| `NetDesk-macOS-universal.dmg` | signed + notarized + stapled disk image (ship this) |
Useful flags: `--skip-build` (reuse the per-target binaries), `--no-sign`
(local unsigned test build), `--no-notarize` (sign but skip the Apple round-
trip), `--identity "…"`, `--icon <png>`, `--version <x.y.z>`, `--entitlements
<plist>`. Run `bash packaging/build-macos.sh --help` for the full list.
---
## Prerequisites (on the Mac)
1. **Xcode command-line tools** (gives `clang`, the SDKs, `lipo`, `iconutil`,
`sips`, `hdiutil`, `codesign`, `xcrun`):
```bash
xcode-select --install
```
A full Xcode install from the App Store also works.
2. **Rust** via rustup. The script adds the two Apple-silicon/Intel std targets
for you, but you can pre-add them:
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add aarch64-apple-darwin x86_64-apple-darwin
```
3. An **Apple Developer Program** membership (needed only for signing +
notarization — a `--no-sign` build needs none). Set that up once, below.
---
## One-time Apple Developer setup
You need a **Developer ID Application** certificate (this is the identity that
lets an app run on *any* Mac after notarization — distinct from the "Apple
Development" / "Apple Distribution" / Mac App Store certs) and a way for
`notarytool` to authenticate.
### 1. Enrol in the Apple Developer Program
<https://developer.apple.com/programs/> — \$99/yr. You must be the **Account
Holder** (or have the Admin role) to create a Developer ID certificate.
### 2. Create the "Developer ID Application" certificate
Easiest, via Xcode:
1. Xcode → **Settings… → Accounts**, sign in with the Apple ID on the team.
2. Select the team → **Manage Certificates…**
3. Click **+** → **Developer ID Application**.
4. Xcode creates the private key + cert and installs both into your **login
keychain**. (Back up the private key — it cannot be re-downloaded.)
Or manually at <https://developer.apple.com/account/resources/certificates> →
**+** → *Developer ID → Developer ID Application*, upload a CSR made with
**Keychain Access → Certificate Assistant → Request a Certificate from a
Certificate Authority**, then double-click the downloaded `.cer` to install it.
Verify it is present and usable for signing:
```bash
security find-identity -v -p codesigning
# look for a line like:
# 1) ABCD… "Developer ID Application: Akronlogic LLC (TEAMID1234)"
```
The script auto-detects this identity; override with `--identity "Developer ID
Application: Akronlogic LLC (TEAMID1234)"` if you have more than one.
### 3. Find your Team ID
<https://developer.apple.com/account> → **Membership details** → *Team ID* (a
10-character string, also shown in the parentheses of the identity name above).
### 4. Create credentials for notarytool
Two supported options — pick one:
**A. App-specific password (simplest).** At <https://appleid.apple.com> →
*Sign-In and Security → App-Specific Passwords* → **+**, label it e.g.
`netdesk-notarytool`. Then store it once in the keychain as a named profile:
```bash
xcrun notarytool store-credentials netdesk \
--apple-id "you@akronlogic.com" \
--team-id "TEAMID1234" \
--password "abcd-efgh-ijkl-mnop" # the app-specific password
```
`netdesk` is the profile name you pass to `--notary-profile netdesk`.
**B. App Store Connect API key.** Create a key at *App Store Connect → Users and
Access → Integrations → App Store Connect API* (role: *Developer*), download the
`AuthKey_XXXX.p8`, and store it:
```bash
xcrun notarytool store-credentials netdesk \
--key "/path/AuthKey_XXXXXXXX.p8" \
--key-id "XXXXXXXXXX" \
--issuer "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
```
> Instead of a stored profile you can pass credentials inline each run:
> `--apple-id you@akronlogic.com --team-id TEAMID1234` **plus** the app-specific
> password in the environment: `export NETDESK_NOTARY_PASSWORD='abcd-efgh-…'`.
---
## What the script does (step by step)
`packaging/build-macos.sh`:
1. **rustup target add** `aarch64-apple-darwin` `x86_64-apple-darwin`.
2. **cargo build --release -p netdesk-viewer** once per target — producing
`target/aarch64-apple-darwin/release/netdesk-viewer` and the `x86_64` one.
(Cross-building the Intel slice on an Apple-silicon Mac uses the same SDK and
needs no extra tooling.)
3. **lipo -create** the two into one universal Mach-O at
`NetDesk.app/Contents/MacOS/NetDesk` (`lipo -info` prints `arm64 x86_64`).
4. **iconutil** builds `NetDesk.icns` from the largest PNG master it finds
(`assets/icon-1024.png` → `icon-512.png` → `icon.png`, or `--icon <png>`):
`sips` resizes the master into a complete `.iconset` (16→1024 px, @1x/@2x),
then `iconutil -c icns` packs it into `Contents/Resources/NetDesk.icns`.
5. **Assembles `NetDesk.app`** — writes `Contents/Info.plist` with bundle id
**`com.akronlogic.netdesk`**, `CFBundleVersion` / `CFBundleShortVersionString`
= the workspace version (read from `Cargo.toml`, currently **0.1.0**),
`CFBundleExecutable`/`CFBundleIconFile` = `NetDesk`, `LSMinimumSystemVersion`
11.0, `NSHighResolutionCapable`, plus a `PkgInfo` (`APPL????`).
6. **codesign** the executable and the bundle with the Developer ID Application
identity, **`--options runtime`** (hardened runtime, required to notarize) and
**`--timestamp`** (secure timestamp), then `codesign --verify --deep --strict`.
7. **Notarize + staple the `.app`**: `ditto -c -k --keepParent` it into a zip,
`xcrun notarytool submit … --wait`, then `xcrun stapler staple NetDesk.app`
so the ticket travels with the app offline. (Skip with `--no-staple-app`.)
8. **Build the `.dmg`** (`hdiutil create`, UDZO, with an `/Applications`
symlink for drag-install), **sign** it, **notarize** it
(`notarytool submit … --wait`), and **staple** it.
If no Developer ID identity is found (or `--no-sign`), it still produces an
**unsigned** `.app` + `.dmg` and tells you so — handy for a local smoke test.
If signing works but no notarization credentials are present, it signs and
prints exactly how to add them, like the Windows installer script does when
NSIS is missing.
---
## Exact commands (equivalent to what the script runs)
If you want to drive it by hand, this is the full sequence (`VERSION=0.1.0`,
`ID="Developer ID Application: Akronlogic LLC (TEAMID1234)"`):
```bash
export PATH="$HOME/.cargo/bin:$PATH"
# 1-2. targets + release builds
rustup target add aarch64-apple-darwin x86_64-apple-darwin
cargo build --release -p netdesk-viewer --target aarch64-apple-darwin
cargo build --release -p netdesk-viewer --target x86_64-apple-darwin
# 3. universal binary
mkdir -p packaging/dist/NetDesk.app/Contents/MacOS packaging/dist/NetDesk.app/Contents/Resources
lipo -create -output packaging/dist/NetDesk.app/Contents/MacOS/NetDesk \
target/aarch64-apple-darwin/release/netdesk-viewer \
target/x86_64-apple-darwin/release/netdesk-viewer
# 4. icon (ICONSET fully populated 16..1024 as in the script)
iconutil -c icns NetDesk.iconset -o packaging/dist/NetDesk.app/Contents/Resources/NetDesk.icns
# 5. Info.plist (the script writes com.akronlogic.netdesk + version 0.1.0)
# 6. sign (hardened runtime + timestamp)
codesign --force --timestamp --options runtime --sign "$ID" packaging/dist/NetDesk.app/Contents/MacOS/NetDesk
codesign --force --timestamp --options runtime --sign "$ID" packaging/dist/NetDesk.app
codesign --verify --deep --strict --verbose=2 packaging/dist/NetDesk.app
# 7. notarize + staple the app
ditto -c -k --keepParent packaging/dist/NetDesk.app packaging/dist/NetDesk.zip
xcrun notarytool submit packaging/dist/NetDesk.zip --keychain-profile netdesk --wait
xcrun stapler staple packaging/dist/NetDesk.app
# 8. dmg -> sign -> notarize -> staple
hdiutil create -volname NetDesk -srcfolder <stage-with-app+Applications-symlink> \
-fs HFS+ -format UDZO -ov packaging/dist/NetDesk-macOS-universal.dmg
codesign --force --timestamp --sign "$ID" packaging/dist/NetDesk-macOS-universal.dmg
xcrun notarytool submit packaging/dist/NetDesk-macOS-universal.dmg --keychain-profile netdesk --wait
xcrun stapler staple packaging/dist/NetDesk-macOS-universal.dmg
```
The script wraps all of this (with the full `.iconset` generation, the plist,
auto-detection, and graceful fallbacks) — prefer it over the manual steps.
---
## Verify a finished build
```bash
# signature valid + hardened runtime + Developer-ID authority
codesign --verify --deep --strict --verbose=2 packaging/dist/NetDesk.app
codesign -dv --verbose=4 packaging/dist/NetDesk.app 2>&1 | grep -E 'Authority|Timestamp|flags|Identifier'
# Gatekeeper will accept it (Developer ID + notarized)
spctl -a -vvv --type exec packaging/dist/NetDesk.app
# notarization ticket is stapled (works offline)
xcrun stapler validate packaging/dist/NetDesk.app
xcrun stapler validate packaging/dist/NetDesk-macOS-universal.dmg
```
`spctl` should print `source=Notarized Developer ID` and `accepted`.
---
## Runtime permissions (host mode)
Signing/notarization are **not** the macOS capture permissions. When the binary
runs as the **host** and first tries to capture the screen or inject input,
macOS prompts the user (TCC) to grant, under **System Settings → Privacy &
Security**:
- **Screen Recording** — to capture the remote screen.
- **Accessibility** — to inject mouse/keyboard input (CGEvent).
These are user-granted at runtime and are **not** code-signing entitlements, so
the default build needs no entitlements file. (If a future host feature drives
*other* apps via Apple Events, add an entitlements plist with the matching keys
and pass it via `--entitlements`.)
---
## Troubleshooting
- **`no identity found` / unsigned output** — the Developer ID Application cert
(and its private key) must be in the login keychain of the user running the
build; confirm with `security find-identity -v -p codesigning`.
- **Notarization `Invalid` / rejected** — fetch the log:
`xcrun notarytool log <submission-id> --keychain-profile netdesk`. Almost
always a missing `--options runtime` (hardened runtime) or a missing secure
`--timestamp`; the script sets both.
- **`The binary is not signed with a valid Developer ID certificate`** — an
*Apple Development* cert was used instead of *Developer ID Application*; only
the latter notarizes for distribution outside the App Store.
- **openh264 fails to cross-compile the x86_64 slice** — it builds C++ via the
`cc` crate; the Xcode CLT clang handles `-arch x86_64` on an arm Mac, so a
full `xcode-select --install` (or Xcode) is enough. If it still fails, build
only the native slice with `--target aarch64-apple-darwin` and drop the lipo
step (the app is then arm64-only).
- **Icon looks soft** — add a 1024×1024 `assets/icon-1024.png` master (the repo
currently ships 256 and 512); the script prefers the largest it finds.
+223
View File
@@ -0,0 +1,223 @@
# Cross-platform hardware H.264 decode
NetDesk's core decodes the host's H.264 (Annex-B) stream into packed-RGB8
[`Frame`]s for the UI to paint. Every platform shares one trait and one output
type, so the viewer never knows which backend is running:
```rust
pub struct Frame { pub w: usize, pub h: usize, pub rgb: Vec<u8> } // w*h*3 bytes
pub trait VideoDecoder { fn decode(&mut self, au: &[u8]) -> Result<Option<Frame>>; }
pub fn best_decoder() -> Box<dyn VideoDecoder + Send>;
```
`best_decoder()` selects the fastest decoder for the current OS at runtime and
**always** falls back to the portable openh264 software decoder if the native
hardware path cannot initialize (no GPU, missing runtime, headless box, etc.).
| Target | Backend | Module | API | Crate(s) |
|---------------|----------------|---------------------------|-----------------------------|---------------------------------|
| Windows | Media Foundation (DXVA2) | `core/src/decode_mf.rs` | `IMFTransform` (MSH264 MFT) | `windows` |
| macOS / iOS | VideoToolbox | `core/src/decode_vt.rs` | `VTDecompressionSession` | `core-foundation(-sys)` + frameworks |
| Android | MediaCodec | `core/src/decode_mediacodec.rs` | `AMediaCodec` (NDK) | `ndk-sys` / `libmediandk` |
| Linux | VAAPI | `core/src/decode_vaapi.rs` | FFmpeg/libavcodec + VAAPI | `ffmpeg-next` |
| All (fallback)| openh264 | `core/src/decode.rs` | software | `openh264` |
Each native module is `#[cfg]`-gated to its own OS, so building for one target
never compiles (or even links) another target's module or its dependencies. The
per-platform crates are declared under `[target.'cfg(...)'.dependencies]` in
`core/Cargo.toml`; on a non-matching host they are only added to `Cargo.lock`
(resolved), never built, and their build scripts never run.
All backends emit the **true display dimensions** (e.g. 1920×1080), cropping out
any codec padding / stride / alignment, so frames are never stretched.
---
## Windows — Media Foundation (reference, builds here)
Already shipping. Build with the portable MinGW toolchain:
```bash
export PATH="/c/mingw-netdesk/mingw64/bin:$HOME/.cargo/bin:$PATH"
cargo build -p netdesk-core # or: --target x86_64-pc-windows-gnu
```
### Dimension fix (2026-10)
The MS H.264 decoder MFT reports the **coded** size in `MF_MT_FRAME_SIZE`, which
is padded up to the codec's macroblock alignment (1080p → a 1088+ coded height;
other resolutions pad larger — this is what produced the wrong `1920x18xx` in the
stats). The real picture rectangle is carried separately in a video-aperture
blob. `decode_mf.rs` now:
1. Reads `MF_MT_FRAME_SIZE` into `coded_w`/`coded_h` — used **only** for the NV12
plane layout (stride × plane height, and the UV plane offset).
2. Reads the display rectangle from, in priority order,
`MF_MT_MINIMUM_DISPLAY_APERTURE` → `MF_MT_GEOMETRIC_APERTURE` →
`MF_MT_PAN_SCAN_APERTURE` (the last only when `MF_MT_PAN_SCAN_ENABLED`), as an
`MFVideoArea` blob giving offset `(x, y)` and visible `width`×`height`. The
aperture is clamped inside the coded frame so a malformed blob can't over-read.
3. `nv12_to_rgb(..)` copies only the visible `width`×`height` region at
`(crop_x, crop_y)` out of the padded NV12 (system-memory and DXGI/D3D11
staging paths both), so `Frame.w`/`Frame.h` are the true `1920×1080`.
When no aperture is present the whole coded frame is treated as the picture
(unchanged behaviour).
---
## macOS / iOS — VideoToolbox
`decode_vt.rs`, gated `#[cfg(any(target_os = "macos", target_os = "ios"))]`.
Feeds AUs into a `VTDecompressionSession` on the platform video engine and
requests 32-bit BGRA output (VideoToolbox does YUV→RGB on the GPU), which is
repacked to RGB8. The session is built lazily from the first keyframe's SPS/PPS
(`CMVideoFormatDescriptionCreateFromH264ParameterSets`); Annex-B is repacked to
AVCC for the `CMSampleBuffer`. `CVPixelBufferGetWidth/Height` already return the
cropped display size, so no manual crop is needed.
Links the system **CoreMedia**, **CoreVideo** and **VideoToolbox** frameworks
directly; `core-foundation` is used for the pixel-buffer attribute dictionary.
### Build (requires macOS + Xcode)
```bash
xcode-select --install # or a full Xcode install
rustup target add aarch64-apple-darwin x86_64-apple-darwin # macOS
rustup target add aarch64-apple-ios aarch64-apple-ios-sim # iOS
cargo build -p netdesk-core --target aarch64-apple-darwin # Apple-silicon mac
cargo build -p netdesk-core --target aarch64-apple-ios # device
cargo build -p netdesk-core --target aarch64-apple-ios-sim # simulator
```
The framework `#[link]`s resolve against the SDK the active toolchain selects
(`xcrun --sdk macosx|iphoneos|iphonesimulator --show-sdk-path`). No extra flags.
### Test
```bash
cargo test -p netdesk-core --target aarch64-apple-darwin
```
Smoke-test end to end by running the viewer against a live host; the log line
`decode: VideoToolbox H.264 (hardware)` confirms the path was selected.
---
## Android — MediaCodec
`decode_mediacodec.rs`, gated `#[cfg(target_os = "android")]`.
Drives a byte-buffer-mode `AMediaCodec` AVC decoder (the SoC hardware decoder via
OMX/Codec2). The codec is configured lazily from the first keyframe's SPS/PPS as
`csd-0`/`csd-1`; each `decode` queues the Annex-B AU as one input buffer and
dequeues one output buffer. Output is YUV 4:2:0 — NV12 semi-planar or I420
planar depending on the device — converted to RGB8 honouring the format's
`stride`, `slice-height`, `color-format`, and `crop-left/top/right/bottom` keys,
so the emitted frame is the true display size.
Talks to the stable NDK Media C ABI in **`libmediandk`** directly (the same ABI
the `ndk-sys` crate binds), so it is version-independent.
### Build (requires the Android NDK)
Install the NDK (via Android Studio's SDK Manager or `sdkmanager "ndk;27.0.x"`),
then use [`cargo-ndk`]:
```bash
rustup target add aarch64-linux-android armv7-linux-androideabi \
x86_64-linux-android i686-linux-android
cargo install cargo-ndk
export ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/27.0.12077973
# API 24+; links libmediandk / liblog automatically via the NDK sysroot.
cargo ndk -t arm64-v8a -t armeabi-v7a -p 24 build -p netdesk-core --release
```
Or without cargo-ndk, point Cargo at the NDK clang linker for each target, e.g.:
```bash
export CC_aarch64_linux_android=$ANDROID_NDK_HOME/.../aarch64-linux-android24-clang
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$CC_aarch64_linux_android
cargo build -p netdesk-core --target aarch64-linux-android --release
```
### Test
Run on a device/emulator (the `mediandk` APIs are not present on the host):
```bash
cargo ndk -t arm64-v8a build -p netdesk-core --tests
# push the test binary with `adb push` and run it on-device, or exercise it
# through the app's instrumented tests.
```
The log line `decode: MediaCodec H.264 (hardware)` confirms selection.
---
## Linux — VAAPI (via FFmpeg)
`decode_vaapi.rs`, gated `#[cfg(target_os = "linux")]`.
Opens the libavcodec H.264 decoder with a VAAPI `hw_device_ctx` (default DRI
render node). libavcodec does all bitstream parsing; decode runs on the GPU, the
surface is pulled to system memory with `av_hwframe_transfer_data` (NV12),
cropped to the display rectangle with `av_frame_apply_cropping`, and converted to
RGB8 with swscale. If no VAAPI device is usable, `new()` errors and the core
falls back to openh264, so GPU-less / headless boxes still work.
Pulls libav* in through the **`ffmpeg-next`** crate and needs the system FFmpeg
(≥ 4) development libraries and a VAAPI driver at build/run time.
### Build (requires system FFmpeg + VAAPI dev packages)
Debian/Ubuntu:
```bash
sudo apt install -y clang pkg-config \
libavcodec-dev libavutil-dev libswscale-dev libavformat-dev \
libva-dev vainfo intel-media-va-driver # or mesa-va-drivers for AMD
cargo build -p netdesk-core # host target
```
Fedora:
```bash
sudo dnf install clang pkgconf-pkg-config \
ffmpeg-devel libva-devel libva-utils mesa-va-drivers
cargo build -p netdesk-core
```
`ffmpeg-sys-next` locates the libraries with `pkg-config`; set
`PKG_CONFIG_PATH` if FFmpeg is in a non-standard prefix. `bindgen` needs
`clang`/`libclang` (the `clang` package above).
### Test
```bash
vainfo # confirm a VAAPI driver + H264 profile
cargo test -p netdesk-core
```
At runtime the log line `decode: VAAPI H.264 (FFmpeg hardware)` confirms the
hardware path; `VAAPI init failed (...); using openh264 software decode` means it
fell back (check `vainfo`, `/dev/dri/renderD128` permissions, and the
`LIBVA_DRIVER_NAME` driver).
---
## Adding another backend
1. Add `core/src/decode_<name>.rs` implementing `VideoDecoder` + `unsafe impl
Send`, with a `new() -> Result<Self>` that errors when the platform can't
support it.
2. Declare the module in `decode.rs` behind the right `#[cfg(...)]`.
3. Add a `#[cfg(...)]` branch in `best_decoder()` before the openh264 fallback.
4. Add the deps under a matching `[target.'cfg(...)'.dependencies]` in
`core/Cargo.toml`.
5. Emit the **true display size** (crop any codec padding) and BT.601 limited-
range RGB to match the other backends.
+32
View File
@@ -0,0 +1,32 @@
# NetDesk flutter_rust_bridge (FRB v2) FFI layer. This crate is the Dart-callable
# surface over `netdesk-core`: the Flutter UI (ui/) calls `connect(...)` plus the
# input/control methods here, and receives a typed event stream (video frames,
# chat, clipboard, lifecycle) over a flutter_rust_bridge StreamSink.
# Developed by Akronlogic (https://akronlogic.com).
[package]
name = "netdesk-frb"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "NetDesk flutter_rust_bridge FFI layer — Dart-callable API over netdesk-core for the Flutter UI. Developed by Akronlogic."
[lib]
name = "netdesk_frb"
# cdylib = the .so/.dll/.dylib the Flutter engine dlopen's on Android/Windows/Linux;
# staticlib = the .a linked into the iOS/macOS app; lib = so the codegen + tests
# can use the crate as a normal Rust library.
crate-type = ["cdylib", "staticlib", "lib"]
# Silence the cosmetic `unexpected_cfgs` warning the `#[frb(sync)]` attribute
# macro emits (it references an internal `frb_expand` cfg); this is the fix
# flutter_rust_bridge documents.
[lints.rust]
unexpected_cfgs = { level = "allow", check-cfg = ['cfg(frb_expand)'] }
[dependencies]
netdesk-core = { path = "../core" }
# Pinned to the installed codegen (flutter_rust_bridge_codegen 2.13.0); FRB
# requires the runtime crate and the codegen to be the same version.
flutter_rust_bridge = "=2.13.0"
tokio = { workspace = true }
+6
View File
@@ -0,0 +1,6 @@
# flutter_rust_bridge v2 codegen config for the NetDesk FFI layer.
# Run from this directory: flutter_rust_bridge_codegen generate
rust_input: crate::api
rust_root: .
dart_output: ../ui/lib/rust
dart_root: ../ui
+632
View File
@@ -0,0 +1,632 @@
//! Dart-callable API (flutter_rust_bridge v2) over `netdesk-core`.
//!
//! Lifecycle from Dart:
//! ```dart
//! final handle = await connect(wsUrl: ..., hostId: ..., password: ..., totp: ...);
//! sessionEvents(handle: handle).listen((ev) { /* NdEvent */ });
//! sendMouse(handle: handle, x: .., y: .., buttons: .., wheel: .., hwheel: ..);
//! disconnect(handle: handle);
//! ```
//!
//! [`connect`] returns a `u64` **session handle** and starts the Tokio +
//! `netdesk_core::connect` work on a background runtime; it is split from
//! [`session_events`] because a flutter_rust_bridge function that takes a
//! `StreamSink` returns a Dart `Stream` (so it cannot also return the handle).
//! `connect` therefore buffers [`NdEvent`]s into a per-handle channel, and
//! `session_events` taps that channel as the Dart-facing stream. All input /
//! control calls ([`send_mouse`], [`send_key`], …) take the handle so the right
//! [`Session`] is found in the global session table.
//!
//! Threading: a single process-wide multi-threaded Tokio runtime owns every
//! session's network + decode task. The live [`Session`] is parked in a global
//! `Mutex<HashMap<u64, Session>>` so the (cheap, synchronous) input/control
//! functions can reach it from the Dart isolate thread without touching async.
use crate::frb_generated::StreamSink;
use flutter_rust_bridge::frb;
use netdesk_core::decode::{best_decoder, best_decoder_for};
use netdesk_core::host::{run_host, HostConfig, HostEvent, HostFrame};
use netdesk_core::{connect as core_connect, crypto, ConnectConfig, NdError, Session, SessionEvent};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use tokio::sync::mpsc;
/// Events streamed from the Rust core to the Flutter UI (the Dart-facing
/// projection of `netdesk_core`'s richer `SessionEvent` — video access units
/// are decoded to paint-ready RGBA frames here).
#[derive(Clone, Debug)]
pub enum NdEvent {
/// The WebSocket is dialing / the handshake is in flight.
Connecting,
/// Session accepted; the remote screen is about to stream.
Connected {
w: u32,
h: u32,
e2e: bool,
host_name: String,
},
/// A decoded, paint-ready frame: tightly-packed RGBA8 (`width*height*4`).
Frame {
width: u32,
height: u32,
rgba: Vec<u8>,
},
/// The session ended (socket closed, host ended it, connect failed, …).
Ended { reason: String },
/// The host refused the session (bad password/TOTP, busy, blocked, …).
Denied { reason: String },
/// A chat message from the host.
ChatMessage { from: String, text: String },
/// Remote clipboard text pushed by the host (RustDesk-style sync).
Clipboard { text: String },
}
/// Process-wide Tokio runtime that owns every session's network + decode task.
fn runtime() -> &'static tokio::runtime::Runtime {
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("netdesk-frb: build tokio runtime")
})
}
/// Live sessions, keyed by the `u64` handle [`connect`] returns.
fn sessions() -> &'static Mutex<HashMap<u64, Session>> {
static SESSIONS: OnceLock<Mutex<HashMap<u64, Session>>> = OnceLock::new();
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Event receivers parked between [`connect`] (the producer) and
/// [`session_events`] (the Dart-facing stream), keyed by the same handle. The
/// channel is unbounded so events emitted before Dart subscribes are buffered,
/// not dropped.
fn pending() -> &'static Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdEvent>>> {
static PENDING: OnceLock<Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdEvent>>>> =
OnceLock::new();
PENDING.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Per-handle "the user asked to stop" flags. The reconnect loop checks this to
/// tell a deliberate [`disconnect`] (leave the session) from a network drop
/// (auto-reconnect). Shared as an `Arc` so the loop keeps its own reference even
/// after `disconnect` removes the map entry.
fn cancels() -> &'static Mutex<HashMap<u64, Arc<AtomicBool>>> {
static CANCELS: OnceLock<Mutex<HashMap<u64, Arc<AtomicBool>>>> = OnceLock::new();
CANCELS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Monotonic source of session handles. Starts at 1 so `0` can mean "none".
static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
/// Expand tightly-packed RGB8 (what the decoders emit) to RGBA8 (what Flutter's
/// `ui.decodeImageFromPixels` / a texture wants), forcing full alpha.
fn rgb_to_rgba(rgb: &[u8], w: usize, h: usize) -> Vec<u8> {
let mut out = vec![0u8; w * h * 4];
for (src, dst) in rgb.chunks_exact(3).zip(out.chunks_exact_mut(4)) {
dst[0] = src[0];
dst[1] = src[1];
dst[2] = src[2];
dst[3] = 0xff;
}
out
}
/// Start a session against `ws_url` / `host_id` with `password` + `totp`, and
/// return its **handle**. The actual connect + handshake + H.264 decode run on
/// the background Tokio runtime; subscribe to the resulting [`NdEvent`]s with
/// [`session_events`] (passing this handle), and drive input / control with the
/// other functions here. A failed connect still returns a valid handle and
/// reports [`NdEvent::Ended`] / [`NdEvent::Denied`] on the stream.
pub fn connect(ws_url: String, host_id: String, password: String, totp: String) -> u64 {
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
let cfg = ConnectConfig {
ws_url,
host_id,
password,
totp,
takeover: false,
};
// UI-facing event channel; `session_events` drains the receiver into a sink.
let (ev_tx, ev_rx) = mpsc::unbounded_channel::<NdEvent>();
pending().lock().unwrap().insert(handle, ev_rx);
// Cancel flag: `disconnect` sets it so the reconnect loop stops instead of
// re-dialing. Kept as an Arc so the loop holds its own reference even after
// `disconnect` removes the map entry.
let cancel = Arc::new(AtomicBool::new(false));
cancels().lock().unwrap().insert(handle, cancel.clone());
runtime().spawn(async move {
// Resilient session: keep the remote alive across network drops, host
// restarts and roaming. On an unexpected disconnect we re-dial the SAME
// host under the SAME handle (so send_mouse/key keep routing) with a
// short backoff, surfacing a `Connecting` blip instead of ending. We
// stop only on a deliberate `disconnect` (cancel) or an auth `Denied`.
let mut backoff: u64 = 1;
let mut first = true;
let mut was_denied = false;
loop {
if cancel.load(Ordering::Relaxed) {
break;
}
if !first {
// reconnecting: tell the UI the link is being re-established.
let _ = ev_tx.send(NdEvent::Connecting);
}
let (tx, mut rx) = mpsc::unbounded_channel::<SessionEvent>();
let session = match core_connect(cfg.clone(), tx).await {
Ok(s) => s,
Err(NdError::Denied(reason)) => {
// Bad password / TOTP / blocked / host offline: never
// succeeds on retry. Surface the real reason and stop.
let _ = ev_tx.send(NdEvent::Denied { reason });
was_denied = true;
break;
}
Err(_) => {
// Transient (server/host not reachable yet): back off + retry.
if cancel.load(Ordering::Relaxed) {
break;
}
tokio::time::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(5);
first = false;
continue;
}
};
// Fresh connection: immediate keyframe + park under the handle. A new
// decoder per connection avoids carrying stale reference frames over
// the gap.
session.request_keyframe();
sessions().lock().unwrap().insert(handle, session);
backoff = 1;
first = false;
let mut decoder = best_decoder();
let mut cur_codec: u8 = 0; // 0 = H.264 (proto::VideoCodec)
while let Some(ev) = rx.recv().await {
match ev {
SessionEvent::Connecting => {
let _ = ev_tx.send(NdEvent::Connecting);
}
SessionEvent::Connected {
host_name,
e2e,
w,
h,
} => {
let _ = ev_tx.send(NdEvent::Connected { w, h, e2e, host_name });
}
SessionEvent::Video { au, keyframe, codec, .. } => {
// Follow the host's per-frame codec tag (H.264 / VP9),
// swapping the decoder only on a keyframe.
if codec != cur_codec {
if keyframe {
decoder = best_decoder_for(codec);
cur_codec = codec;
} else {
continue;
}
}
// Feed the access unit to the active decoder; emit a frame
// only when it produces a picture.
if let Ok(Some(frame)) = decoder.decode(&au) {
let rgba = rgb_to_rgba(&frame.rgb, frame.w, frame.h);
let _ = ev_tx.send(NdEvent::Frame {
width: frame.w as u32,
height: frame.h as u32,
rgba,
});
}
}
SessionEvent::ChatMessage { from, text } => {
let _ = ev_tx.send(NdEvent::ChatMessage { from, text });
}
SessionEvent::Clipboard { text } => {
let _ = ev_tx.send(NdEvent::Clipboard { text });
}
SessionEvent::Denied { reason } => {
let _ = ev_tx.send(NdEvent::Denied { reason });
was_denied = true;
break;
}
SessionEvent::Ended { .. } => {
// Connection dropped: leave the inner loop and reconnect
// (unless the user asked to stop, checked below).
break;
}
// Challenged / Audio / Info / SysResult / file-transfer events
// are not surfaced to the mobile UI in this phase.
_ => {}
}
}
sessions().lock().unwrap().remove(&handle);
if was_denied || cancel.load(Ordering::Relaxed) {
break;
}
// Unexpected drop: brief pause, then reconnect (top emits Connecting).
tokio::time::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(5);
}
// Finished for good (user left or auth denied): clean up + close the UI
// stream so Dart's `.listen` onDone fires.
sessions().lock().unwrap().remove(&handle);
cancels().lock().unwrap().remove(&handle);
// Only emit a generic Ended when the user left (cancel). A Denied already
// carried its real reason (wrong password / host offline / busy) - don't
// overwrite it with "disconnected".
if !was_denied {
let _ = ev_tx.send(NdEvent::Ended {
reason: "disconnected".into(),
});
}
});
handle
}
/// Subscribe to the [`NdEvent`] stream for a `handle` returned by [`connect`].
/// In Dart this is `Stream<NdEvent> sessionEvents({required BigInt handle})`.
/// Call it once per handle, right after `connect`.
pub fn session_events(handle: u64, sink: StreamSink<NdEvent>) {
let rx = pending().lock().unwrap().remove(&handle);
if let Some(mut rx) = rx {
runtime().spawn(async move {
while let Some(ev) = rx.recv().await {
if sink.add(ev).is_err() {
break; // Dart dropped the subscription
}
}
});
}
}
/// Run a helper against the live session for `handle`, if it still exists.
fn with_session<R>(handle: u64, f: impl FnOnce(&Session) -> R) -> Option<R> {
sessions().lock().unwrap().get(&handle).map(f)
}
/// Send a pointer event (coordinates in the remote screen's pixel space).
/// `buttons` is the button bitmask, `wheel`/`hwheel` are scroll deltas.
#[frb(sync)]
pub fn send_mouse(handle: u64, x: i32, y: i32, buttons: u32, wheel: f32, hwheel: f32) {
with_session(handle, |s| s.mouse(x, y, buttons, wheel, hwheel));
}
/// Send a key event. `code` is a DOM-style key code (e.g. "KeyA", "Enter").
#[frb(sync)]
pub fn send_key(handle: u64, code: String, down: bool) {
with_session(handle, |s| s.key(&code, down));
}
/// Reconfigure the host encoder: target `fps`, `bitrate` (kbps), resolution `scale`.
#[frb(sync)]
pub fn set_quality(handle: u64, fps: u32, bitrate: u32, scale: f32) {
with_session(handle, |s| s.set_quality(fps, bitrate, scale));
}
/// Switch the captured monitor to index `i` (as advertised by
/// [`session_monitors`]). Immediately asks the host for a fresh keyframe so the
/// new display paints without waiting for the next natural IDR — the messages
/// travel the same WebSocket in order, so the keyframe is of the *new* monitor.
#[frb(sync)]
pub fn monitor_switch(handle: u64, i: i32) {
with_session(handle, |s| {
s.monitor_switch(i);
s.request_keyframe();
});
}
/// A display the host advertised at accept time (index + pixel size).
#[derive(Clone, Debug)]
pub struct NdMonitor {
pub i: i32,
pub w: u32,
pub h: u32,
}
/// The real list of displays the host reported (its `info.monitors`). The UI
/// uses this to show exactly the monitors that exist — never a hardcoded count.
/// Returns empty until the session is accepted, or if the host sent none.
#[frb(sync)]
pub fn session_monitors(handle: u64) -> Vec<NdMonitor> {
with_session(handle, |s| {
s.monitors()
.iter()
.map(|m| NdMonitor {
i: m.i,
w: m.w,
h: m.h,
})
.collect()
})
.unwrap_or_default()
}
/// The monitor index the host is currently capturing (as reported at accept).
#[frb(sync)]
pub fn session_current_monitor(handle: u64) -> i32 {
with_session(handle, |s| s.current_monitor()).unwrap_or(0)
}
/// Send Ctrl+Alt+Del on the host.
#[frb(sync)]
pub fn ctrl_alt_del(handle: u64) {
with_session(handle, |s| s.ctrl_alt_del());
}
/// Send a chat message to the host.
#[frb(sync)]
pub fn send_chat(handle: u64, text: String) {
with_session(handle, |s| s.chat(&text));
}
/// Push text to the remote clipboard (and announce our local clipboard).
#[frb(sync)]
pub fn set_clipboard(handle: u64, text: String) {
with_session(handle, |s| s.clipboard(&text));
}
/// End the session: tell the host, then drop the handle. The event stream
/// receives a final [`NdEvent::Ended`] when the network task unwinds.
#[frb(sync)]
pub fn disconnect(handle: u64) {
// Tell the reconnect loop to stop re-dialing, then tear down the live
// connection. The loop sees the cancel flag after the socket closes and
// exits instead of auto-reconnecting.
if let Some(c) = cancels().lock().unwrap().get(&handle) {
c.store(true, Ordering::Relaxed);
}
pending().lock().unwrap().remove(&handle);
if let Some(s) = sessions().lock().unwrap().remove(&handle) {
s.close();
}
}
// ===========================================================================
// HOST role — let a viewer connect to and control THIS device (Android host).
// Mirrors the viewer API: host_start() returns a handle; host_events() taps the
// input/control stream; host_push_frame() feeds encoded H.264 from the platform
// encoder (MediaProjection+MediaCodec); host_stop() tears it down. The run loop
// auto-reconnects (backoff 1-5s) so the device stays reachable.
// ===========================================================================
/// Dart-facing projection of `netdesk_core::host::HostEvent`.
#[derive(Clone, Debug)]
pub enum NdHostEvent {
/// Registered with the server; the device is reachable at its host id.
Registered,
/// A viewer authenticated; the session is live (inject input, feed frames).
ViewerConnected { e2e: bool },
/// The viewer left; the host stays registered for the next one.
ViewerLeft,
/// Pointer in the captured image's pixel space (buttons = bitmask).
Mouse { x: i32, y: i32, buttons: u32, wheel: f32 },
/// Key: DOM-style code + down/up.
Key { code: String, down: bool },
/// The viewer asked for a fresh keyframe — request one from the encoder.
KeyframeRequested,
/// The viewer changed quality targets.
Quality { fps: u32, bitrate: u32, scale: f32 },
/// The server link dropped; auto-reconnecting.
Reconnecting,
/// The host loop stopped for good (host_stop / fatal).
Ended { reason: String },
}
fn map_host_event(e: HostEvent) -> Option<NdHostEvent> {
Some(match e {
HostEvent::Registered => NdHostEvent::Registered,
HostEvent::ViewerConnected { e2e, .. } => NdHostEvent::ViewerConnected { e2e },
HostEvent::ViewerLeft => NdHostEvent::ViewerLeft,
HostEvent::Mouse { x, y, buttons, wheel } => NdHostEvent::Mouse { x, y, buttons, wheel },
HostEvent::Key { code, down } => NdHostEvent::Key { code, down },
// Relative/game mouse, Unicode (translate-keyboard) text, and the
// annotation overlay are desktop-host input/overlay modes the native
// engine handles directly; the Android host bridge doesn't surface them.
HostEvent::MouseRel { .. } => return None,
HostEvent::Text { .. } => return None,
HostEvent::Annot { .. } => return None,
HostEvent::KeyframeRequested => NdHostEvent::KeyframeRequested,
HostEvent::Quality { fps, bitrate, scale } => NdHostEvent::Quality { fps, bitrate, scale },
HostEvent::Ended { reason } => NdHostEvent::Ended { reason },
// Android host clipboard-to-host / sys / file controls are not wired yet.
HostEvent::Clipboard { .. } => return None,
HostEvent::ClipboardImage { .. } => return None,
HostEvent::Sys { .. } => return None,
HostEvent::File { .. } => return None,
HostEvent::FileChunk { .. } => return None,
// Voice-call playback on the Android host is not wired yet (needs an
// Android AudioTrack sink); the native desktop host handles it.
HostEvent::Voice { .. } => return None,
// Adaptive-QoS telemetry is consumed by the native engine, not the UI.
HostEvent::NetStat { .. } => return None,
// The viewer's audio-source pick is handled by the native capture/audio
// engine; the Android host UI doesn't surface it.
HostEvent::AudioSource { .. } => return None,
// Codec pin is consumed by the native capture engine's codec selector.
HostEvent::PinCodec { .. } => return None,
// Codec capability is consumed by the native engine's codec selector.
HostEvent::ViewerCaps { .. } => return None,
// Monitor switching is handled by the native capture engine (the Android
// host is single-display).
HostEvent::SwitchMonitor { .. } => return None,
// Approve-before-connect is off on Android (cfg.approve=false), so this
// never fires; view-only is enforced inside the core engine already.
HostEvent::ApproveRequest { .. } => return None,
HostEvent::ViewOnly { .. } => return None,
// TCP tunneling is a desktop-host feature; the Android host doesn't offer it.
HostEvent::TunnelOpen { .. } => return None,
HostEvent::TunnelClose { .. } => return None,
HostEvent::TunnelData { .. } => return None,
// Interactive terminal is a desktop-host feature; not on the Android host.
HostEvent::TermOpen { .. } => return None,
HostEvent::TermInput { .. } => return None,
HostEvent::TermResize { .. } => return None,
HostEvent::TermClose { .. } => return None,
})
}
type FrameSlot = Arc<Mutex<Option<mpsc::UnboundedSender<HostFrame>>>>;
/// Per-handle current frame sender (swapped each (re)connect so pushed frames go
/// to the live run, or are dropped between runs).
fn host_frames() -> &'static Mutex<HashMap<u64, FrameSlot>> {
static M: OnceLock<Mutex<HashMap<u64, FrameSlot>>> = OnceLock::new();
M.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Per-handle Dart-facing event receivers, parked until host_events() taps them.
fn host_pending() -> &'static Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdHostEvent>>> {
static M: OnceLock<Mutex<HashMap<u64, mpsc::UnboundedReceiver<NdHostEvent>>>> = OnceLock::new();
M.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Per-handle "stop" flags for the host reconnect loop.
fn host_cancels() -> &'static Mutex<HashMap<u64, Arc<AtomicBool>>> {
static M: OnceLock<Mutex<HashMap<u64, Arc<AtomicBool>>>> = OnceLock::new();
M.get_or_init(|| Mutex::new(HashMap::new()))
}
/// A fresh 9-digit host id to persist on the device (shown to viewers).
#[frb(sync)]
pub fn host_gen_id() -> String {
crypto::gen_host_id()
}
/// A fresh friendly unattended password (no look-alike chars).
#[frb(sync)]
pub fn host_gen_password() -> String {
crypto::gen_password(8)
}
/// Start hosting: register `host_id` with the server and serve one viewer at a
/// time, authenticating against `password`. Returns a handle; drive input/
/// control via [`host_events`], feed video via [`host_push_frame`], stop with
/// [`host_stop`]. Credentials are derived fresh from `password` each run (the
/// challenge carries the salt, so the viewer only needs the password).
pub fn host_start(
ws_url: String,
host_id: String,
name: String,
password: String,
width: u32,
height: u32,
) -> u64 {
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
let (salt_b64, iters, verifier_hex) = crypto::new_host_credentials(&password);
let cfg = HostConfig {
ws_url,
host_id,
name,
salt_b64,
iters,
verifier_hex,
width,
height,
audio: false,
monitors: Vec::new(),
monitor: 0,
// Android host: all channels allowed (the app's own permission flow —
// MediaProjection consent + accessibility — is the gate), no approve
// prompt (the consent dialog already is one).
perms: Default::default(),
approve: false,
totp_secret: String::new(),
};
let frame_slot: FrameSlot = Arc::new(Mutex::new(None));
host_frames().lock().unwrap().insert(handle, frame_slot.clone());
let (ev_tx, ev_rx) = mpsc::unbounded_channel::<NdHostEvent>();
host_pending().lock().unwrap().insert(handle, ev_rx);
let cancel = Arc::new(AtomicBool::new(false));
host_cancels().lock().unwrap().insert(handle, cancel.clone());
runtime().spawn(async move {
// One core-event channel for the whole host lifetime; the mapper forwards
// (and translates) to the Dart-facing stream across reconnects.
let (core_tx, mut core_rx) = mpsc::unbounded_channel::<HostEvent>();
let ev_tx_map = ev_tx.clone();
let mapper = runtime().spawn(async move {
while let Some(e) = core_rx.recv().await {
if let Some(m) = map_host_event(e) {
if ev_tx_map.send(m).is_err() {
break;
}
}
}
});
let mut backoff: u64 = 1;
loop {
if cancel.load(Ordering::Relaxed) {
break;
}
let (ftx, frx) = mpsc::unbounded_channel::<HostFrame>();
*frame_slot.lock().unwrap() = Some(ftx);
// Android host does not push outbound control yet; drop the sender so
// run_host disables that arm.
let (_ctl_tx, ctl_rx) = mpsc::unbounded_channel::<netdesk_core::host::HostControl>();
let _ = run_host(cfg.clone(), frx, core_tx.clone(), ctl_rx).await;
*frame_slot.lock().unwrap() = None; // drop frames while offline
if cancel.load(Ordering::Relaxed) {
break;
}
let _ = ev_tx.send(NdHostEvent::Reconnecting);
tokio::time::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(5);
}
mapper.abort();
host_frames().lock().unwrap().remove(&handle);
host_cancels().lock().unwrap().remove(&handle);
let _ = ev_tx.send(NdHostEvent::Ended {
reason: "stopped".into(),
});
});
handle
}
/// Subscribe to the [`NdHostEvent`] stream for a `handle` from [`host_start`].
pub fn host_events(handle: u64, sink: StreamSink<NdHostEvent>) {
let rx = host_pending().lock().unwrap().remove(&handle);
if let Some(mut rx) = rx {
runtime().spawn(async move {
while let Some(ev) = rx.recv().await {
if sink.add(ev).is_err() {
break;
}
}
});
}
}
/// Feed one encoded H.264 access unit (Annex-B) from the platform encoder. Cheap
/// + non-blocking; dropped when no viewer is connected.
#[frb(sync)]
pub fn host_push_frame(handle: u64, data: Vec<u8>, keyframe: bool) {
if let Some(slot) = host_frames().lock().unwrap().get(&handle) {
if let Some(tx) = slot.lock().unwrap().as_ref() {
// Android host encodes H.264 (MediaCodec) -> codec id 0.
let _ = tx.send(HostFrame { data, keyframe, codec: 0 });
}
}
}
/// Stop hosting: end the reconnect loop and drop the handle.
#[frb(sync)]
pub fn host_stop(handle: u64) {
if let Some(c) = host_cancels().lock().unwrap().get(&handle) {
c.store(true, Ordering::Relaxed);
}
// dropping the frame sender unblocks run_host so the loop can see `cancel`
if let Some(slot) = host_frames().lock().unwrap().get(&handle) {
*slot.lock().unwrap() = None;
}
host_pending().lock().unwrap().remove(&handle);
}
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
//! NetDesk flutter_rust_bridge FFI layer. The Dart-facing API lives in [`api`];
//! `frb_generated` is produced by `flutter_rust_bridge_codegen generate` and
//! wires the C ABI the Flutter engine calls. Developed by Akronlogic.
pub mod api;
mod frb_generated;
+81
View File
@@ -0,0 +1,81 @@
# netdesk-host — the NATIVE RUST host (screen capture + H.264 encode + input
# injection) that feeds netdesk_core::host::run_host. This replaces the Python
# host engine so the Windows app is one unified Rust binary (host + viewer).
# Developed by Akronlogic.
[package]
name = "netdesk-host"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
netdesk-core = { path = "../core" }
tokio = { workspace = true }
anyhow = { workspace = true }
serde_json = { workspace = true }
log = "0.4"
# OS clipboard for viewer->host clipboard sync (RustDesk-style).
arboard = "3"
# Encode extracted custom-cursor bitmaps to PNG for cursor.shape (the viewers
# render data:image/png). Base64 of the bytes is done in core on send.
png = "0.17"
env_logger = "0.11"
tracing = "0.1"
tracing-subscriber = { workspace = true }
openh264 = { workspace = true }
[target.'cfg(windows)'.dependencies]
# VP9 software encode (libvpx) for constrained links — the constrained-link
# codec alongside the hardware H.264 path.
netdesk-vpx = { path = "../vpx" }
# Opus encode for system-audio capture (WASAPI loopback -> Opus -> CHAN_AUDIO).
netdesk-opus = { path = "../opus" }
windows = { version = "0.58", features = [
"Win32_Media_Audio",
"Win32_Media_KernelStreaming",
"Win32_System_Com_StructuredStorage",
"Win32_Foundation",
"Win32_System_Com",
"Win32_System_Threading",
"Win32_System_Console",
"Win32_System_Pipes",
"Win32_System_IO",
"Win32_Security",
"Win32_System_Power",
"Win32_Storage_FileSystem",
"Win32_System_SystemInformation",
"Win32_System_RemoteDesktop",
"Win32_System_Services",
"Win32_System_StationsAndDesktops",
"Win32_Security_Authentication_Identity",
"Win32_System_ProcessStatus",
"Win32_System_Diagnostics_ToolHelp",
"Win32_Media_MediaFoundation",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Gdi",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_HiDpi",
] }
[[bin]]
name = "netdesk-host-probe"
path = "src/bin/probe.rs"
[[bin]]
name = "netdesk-host-encbench"
path = "src/bin/encbench.rs"
[[bin]]
name = "netdesk-host-engine"
path = "src/bin/engine.rs"
# Privacy-mode feasibility probe: does WDA_EXCLUDEFROMCAPTURE hide a window from
# this host's own DXGI + GDI capture? (determines the privacy-mode mechanism)
[[bin]]
name = "netdesk-privacy-probe"
path = "src/bin/privacy_probe.rs"
+261
View File
@@ -0,0 +1,261 @@
//! Collaborative annotation overlay. A fullscreen, topmost, click-through,
//! color-keyed LAYERED window on which the remote viewer's ink is drawn, so the
//! person AT the host sees the arrows/circles the operator draws (RustDesk-style
//! "annotate"). Only the strokes are opaque; the rest of the window is a magenta
//! color-key that is fully transparent, so the real desktop shows through and
//! the host user's mouse/keyboard pass straight to it (WS_EX_TRANSPARENT).
//!
//! Marked `WDA_EXCLUDEFROMCAPTURE` so the overlay does NOT appear in the host's
//! own capture — the drawing viewer already renders its ink locally, so this
//! prevents a double image on that viewer. (Relaying strokes to OTHER viewers so
//! they also see the ink is a follow-on.) Modeled on `privacy.rs`; same
//! own-thread + message-pump lifecycle, dropped to clear + remove the overlay.
#![cfg(windows)]
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread::JoinHandle;
use std::time::Duration;
use windows::core::w;
use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::Graphics::Gdi::{
BeginPaint, CreatePen, CreateSolidBrush, DeleteObject, EndPaint, FillRect, InvalidateRect,
LineTo, MoveToEx, SelectObject, HGDIOBJ, PAINTSTRUCT, PS_SOLID,
};
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetSystemMetrics,
PeekMessageW, RegisterClassW, SetLayeredWindowAttributes, SetWindowDisplayAffinity, ShowWindow,
TranslateMessage, LWA_COLORKEY, MSG, PM_REMOVE, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN,
SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_SHOWNA, WDA_EXCLUDEFROMCAPTURE, WM_PAINT, WNDCLASSW,
WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_POPUP,
WS_VISIBLE,
};
/// One pen stroke in VIRTUAL-DESKTOP pixel coordinates.
#[derive(Clone)]
pub struct Stroke {
pub color: u32, // 0xRRGGBB
pub width: i32,
pub points: Vec<(i32, i32)>,
}
// The magenta transparent color-key (COLORREF is 0x00BBGGRR: B=FF, G=00, R=FF).
const KEY_BGR: u32 = 0x00FF_00FF;
static STROKES: OnceLock<Mutex<Vec<Stroke>>> = OnceLock::new();
static ORIGIN: OnceLock<(i32, i32)> = OnceLock::new(); // virtual-desktop top-left
fn strokes() -> &'static Mutex<Vec<Stroke>> {
STROKES.get_or_init(|| Mutex::new(Vec::new()))
}
#[inline]
fn to_bgr(rgb: u32) -> u32 {
let r = (rgb >> 16) & 0xFF;
let g = (rgb >> 8) & 0xFF;
let b = rgb & 0xFF;
(b << 16) | (g << 8) | r
}
unsafe extern "system" fn wndproc(h: HWND, m: u32, w: WPARAM, l: LPARAM) -> LRESULT {
if m == WM_PAINT {
let mut ps = PAINTSTRUCT::default();
let hdc = BeginPaint(h, &mut ps);
// Paint the whole client area with the transparent color-key first.
let keybrush = CreateSolidBrush(COLORREF(KEY_BGR));
FillRect(hdc, &ps.rcPaint, keybrush);
let _ = DeleteObject(HGDIOBJ(keybrush.0));
let (ox, oy) = *ORIGIN.get().unwrap_or(&(0, 0));
if let Ok(list) = strokes().lock() {
for st in list.iter() {
if st.points.is_empty() {
continue;
}
let pen = CreatePen(PS_SOLID, st.width.max(1), COLORREF(to_bgr(st.color)));
let old = SelectObject(hdc, HGDIOBJ(pen.0));
let p0 = st.points[0];
let _ = MoveToEx(hdc, p0.0 - ox, p0.1 - oy, None);
for p in &st.points[1..] {
let _ = LineTo(hdc, p.0 - ox, p.1 - oy);
}
if st.points.len() == 1 {
// a single tap: a 1px nub so it is visible
let _ = LineTo(hdc, p0.0 - ox + 1, p0.1 - oy + 1);
}
SelectObject(hdc, old);
let _ = DeleteObject(HGDIOBJ(pen.0));
}
}
let _ = EndPaint(h, &ps);
return LRESULT(0);
}
DefWindowProcW(h, m, w, l)
}
/// A live annotation overlay. Drop it to clear + remove the overlay.
pub struct Annotations {
stop: Arc<AtomicBool>,
dirty: Arc<AtomicBool>,
hwnd: Arc<AtomicIsize>,
thread: Option<JoinHandle<()>>,
}
impl Annotations {
/// Raise the (empty, invisible) overlay; strokes appear as they are added.
pub fn start() -> Annotations {
if let Ok(mut v) = strokes().lock() {
v.clear();
}
let stop = Arc::new(AtomicBool::new(false));
let dirty = Arc::new(AtomicBool::new(true));
let hwnd = Arc::new(AtomicIsize::new(0));
let (s2, d2, h2) = (stop.clone(), dirty.clone(), hwnd.clone());
let thread = std::thread::Builder::new()
.name("netdesk-annot".into())
.spawn(move || unsafe { run(s2, d2, h2) })
.ok();
Annotations { stop, dirty, hwnd, thread }
}
/// Append a stroke (virtual-desktop coords) and request a repaint.
pub fn add_stroke(&self, s: Stroke) {
if let Ok(mut v) = strokes().lock() {
v.push(s);
}
self.dirty.store(true, Ordering::Relaxed);
}
/// Erase all strokes.
pub fn clear(&self) {
if let Ok(mut v) = strokes().lock() {
v.clear();
}
self.dirty.store(true, Ordering::Relaxed);
}
/// The overlay window handle (0 until created) — for tests.
pub fn hwnd(&self) -> isize {
self.hwnd.load(Ordering::Relaxed)
}
}
impl Drop for Annotations {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
if let Ok(mut v) = strokes().lock() {
v.clear();
}
}
}
unsafe fn run(stop: Arc<AtomicBool>, dirty: Arc<AtomicBool>, hwnd_out: Arc<AtomicIsize>) {
let hinst = HINSTANCE::default();
let keybrush = CreateSolidBrush(COLORREF(KEY_BGR));
let class = w!("NdAnnotOverlay");
let wc = WNDCLASSW {
lpfnWndProc: Some(wndproc),
hInstance: hinst,
lpszClassName: class,
hbrBackground: keybrush,
..Default::default()
};
let _ = RegisterClassW(&wc);
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
let y = GetSystemMetrics(SM_YVIRTUALSCREEN);
let cx = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1);
let cy = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1);
let _ = ORIGIN.set((x, y));
let hwnd = match CreateWindowExW(
WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_NOACTIVATE,
class,
w!("nd-annot"),
WS_POPUP | WS_VISIBLE,
x,
y,
cx,
cy,
None,
None,
hinst,
None,
) {
Ok(h) => h,
Err(e) => {
log::warn!("annot: window create failed: {e}");
return;
}
};
// Magenta is the transparent color-key: only the strokes are opaque/visible.
let _ = SetLayeredWindowAttributes(hwnd, COLORREF(KEY_BGR), 0, LWA_COLORKEY);
// Keep the overlay out of OUR OWN capture (the drawing viewer renders ink
// locally, so this prevents a double image on it).
if let Err(e) = SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE) {
log::warn!("annot: SetWindowDisplayAffinity failed: {e}");
}
let _ = ShowWindow(hwnd, SW_SHOWNA);
hwnd_out.store(hwnd.0 as isize, Ordering::Relaxed);
log::info!("annot: overlay up ({cx}x{cy} @ {x},{y}); click-through, excluded from capture");
let mut msg = MSG::default();
while !stop.load(Ordering::Relaxed) {
if dirty.swap(false, Ordering::Relaxed) {
let _ = InvalidateRect(hwnd, None, false);
}
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
std::thread::sleep(Duration::from_millis(16));
}
let _ = DestroyWindow(hwnd);
hwnd_out.store(0, Ordering::Relaxed);
log::info!("annot: overlay down");
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
use windows::Win32::UI::WindowsAndMessaging::{GetWindowLongPtrW, GWL_EXSTYLE};
/// The overlay must be a LAYERED + click-through (TRANSPARENT) + TOPMOST
/// window and must accept strokes + clear without panicking. Proves the
/// collaborative-overlay mechanism (pixel rendering is verified live after
/// the capstone redeploy).
#[test]
fn overlay_is_layered_clickthrough_topmost_and_takes_strokes() {
let a = Annotations::start();
let end = Instant::now() + Duration::from_millis(1500);
while a.hwnd() == 0 && Instant::now() < end {
std::thread::sleep(Duration::from_millis(20));
}
let h = a.hwnd();
assert!(h != 0, "overlay window did not create");
let ex = unsafe { GetWindowLongPtrW(HWND(h as *mut core::ffi::c_void), GWL_EXSTYLE) } as u32;
assert!(ex & WS_EX_LAYERED.0 != 0, "overlay must be layered");
assert!(ex & WS_EX_TRANSPARENT.0 != 0, "overlay must be click-through");
assert!(ex & WS_EX_TOPMOST.0 != 0, "overlay must be topmost");
a.add_stroke(Stroke {
color: 0xFF0000,
width: 4,
points: vec![(100, 100), (400, 300)],
});
a.add_stroke(Stroke {
color: 0x00AAFF,
width: 2,
points: vec![(50, 50)],
});
std::thread::sleep(Duration::from_millis(120));
a.clear();
std::thread::sleep(Duration::from_millis(60));
println!("ANNOT_OVERLAY_OK hwnd={h:#x} exstyle={ex:#x}");
drop(a);
}
}
+551
View File
@@ -0,0 +1,551 @@
//! System-audio capture for the native host: WASAPI loopback (capture "what you
//! hear" from the default render endpoint) -> downmix to 48 kHz stereo i16 ->
//! Opus (20 ms frames) -> `on_packet`. The host engine seals each packet and
//! sends it as a `CHAN_AUDIO` frame.
//!
//! Off by default (NETDESK_AUDIO=1 to enable), so it can never affect a normal
//! session. Entirely best-effort: any WASAPI error logs once and ends the
//! thread — audio just will not stream, video/input are unaffected.
//!
//! Stock cpal/soundio cannot do WASAPI loopback (RustDesk pins a cpal fork for
//! it), so we drive WASAPI directly through the `windows` crate — no extra
//! dependency and full control over the format path.
//!
//! Developed by Akronlogic.
#![cfg(windows)]
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::Arc;
use std::time::Duration;
use netdesk_opus::{Decoder, Encoder, FRAME_SIZE, SAMPLE_RATE};
use std::collections::VecDeque;
use windows::core::Interface;
use windows::Win32::Media::Audio::{
eCapture, eConsole, eRender, IAudioCaptureClient, IAudioClient, IAudioRenderClient,
IMMDeviceEnumerator, MMDeviceEnumerator, AUDCLNT_BUFFERFLAGS_SILENT, AUDCLNT_SHAREMODE_SHARED,
AUDCLNT_STREAMFLAGS_LOOPBACK, WAVEFORMATEX, WAVEFORMATEXTENSIBLE,
};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoTaskMemFree, CLSCTX_ALL, COINIT_MULTITHREADED,
};
const WAVE_FORMAT_PCM: u16 = 1;
const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
/// 1 second loopback buffer (100-ns units) — generous so draining can lag while
/// idle without overrunning.
const BUFFER_HNS: i64 = 10_000_000;
/// Capture loopback audio until `stop`, encoding Opus only while `active`
/// (a viewer is connected). Each Opus packet is handed to `on_packet`.
///
/// Returns (ending the thread) on any fatal WASAPI/Opus error.
pub fn run_loopback_opus(
stop: Arc<AtomicBool>,
active: impl Fn() -> bool,
mut on_packet: impl FnMut(Vec<u8>),
) {
// Back-compat shim: always-on system loopback.
let src = Arc::new(AtomicU8::new(1));
run_audio(stop, active, src, move |p| on_packet(p))
}
/// On-demand host audio. `source` selects the live source and may change at
/// runtime (the viewer picks it): 0 = off, 1 = SYSTEM (WASAPI render loopback,
/// "what you hear"), 2 = MICROPHONE (default input). Opus packets go to
/// `on_packet` only while `active` (a viewer is streaming). Changing the source
/// re-opens the correct device.
pub fn run_audio(
stop: Arc<AtomicBool>,
active: impl Fn() -> bool,
source: Arc<AtomicU8>,
mut on_packet: impl FnMut(Vec<u8>),
) {
unsafe {
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
}
while !stop.load(Ordering::Relaxed) {
let src = source.load(Ordering::Relaxed);
if src == 0 {
std::thread::sleep(Duration::from_millis(200));
continue;
}
if let Err(e) = capture(&stop, &active, &source, src, &mut on_packet) {
// DEVICE_INVALIDATED (0x88890004), a device format change, or a
// default-endpoint switch all land here. Re-open the (possibly new)
// default endpoint after a short backoff — non-fatal, video/input
// are unaffected and the source selection is preserved.
log::warn!("audio: capture ended (src={src}): {e} — re-opening endpoint");
std::thread::sleep(Duration::from_millis(300));
}
}
}
unsafe fn format_params(mix: *const WAVEFORMATEX) -> (u32, u16, u16, bool) {
// (sample_rate, channels, bits_per_sample, is_float)
let base = &*mix;
let mut is_float = base.wFormatTag == WAVE_FORMAT_IEEE_FLOAT;
if base.wFormatTag == WAVE_FORMAT_EXTENSIBLE {
// SubFormat GUID Data1: 3 = IEEE float, 1 = PCM.
let ext = &*(mix as *const WAVEFORMATEXTENSIBLE);
is_float = ext.SubFormat.data1 == WAVE_FORMAT_IEEE_FLOAT as u32;
}
(
base.nSamplesPerSec,
base.nChannels,
base.wBitsPerSample,
is_float,
)
}
fn capture(
stop: &AtomicBool,
active: &impl Fn() -> bool,
source: &AtomicU8,
src: u8,
on_packet: &mut impl FnMut(Vec<u8>),
) -> anyhow::Result<()> {
unsafe {
// COM: ignore the HRESULT — a non-S_OK (already initialized on this
// thread with a different mode) is fine for our dedicated thread.
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
let enumerator: IMMDeviceEnumerator =
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?;
// src 2 = microphone: default CAPTURE device, no loopback flag.
// else (system): default RENDER device captured in loopback mode.
let (flow, stream_flags) = if src == 2 {
(eCapture, 0u32)
} else {
(eRender, AUDCLNT_STREAMFLAGS_LOOPBACK)
};
let device = enumerator.GetDefaultAudioEndpoint(flow, eConsole)?;
let client: IAudioClient = device.Activate(CLSCTX_ALL, None)?;
let mix = client.GetMixFormat()?;
let (rate, channels, bits, is_float) = format_params(mix);
let channels = channels as usize;
// Resample to 48 kHz only when the device mix rate differs. On Windows the
// shared-mode mix is usually already 48000, so the common path is a no-op.
// Previously a 44100 Hz device (many USB headsets / Bluetooth / capture
// cards) DISABLED audio entirely via a hard bail; now we capture at the
// device's native rate and resample the stereo stream to the 48 kHz the
// Opus encoder and the web AudioDecoder both require.
let need_resample = rate != SAMPLE_RATE;
let mut resampler = LinearResampler::new(rate.max(1), SAMPLE_RATE);
if need_resample {
log::info!("audio: device mix {rate} Hz, {channels}ch -> resampling to 48000 stereo (src={src})");
}
client.Initialize(
AUDCLNT_SHAREMODE_SHARED,
stream_flags,
BUFFER_HNS,
0,
mix,
None,
)?;
CoTaskMemFree(Some(mix as *const _));
let capture: IAudioCaptureClient = client.GetService()?;
client.Start()?;
let mut enc = Encoder::new(SAMPLE_RATE, 2)?;
let mut accum: Vec<i16> = Vec::with_capacity(FRAME_SIZE * 2 * 4);
let frame_len = FRAME_SIZE * 2; // interleaved stereo samples per 20ms
// Exit (so run_audio re-opens) when the viewer changes the source.
while !stop.load(Ordering::Relaxed) && source.load(Ordering::Relaxed) == src {
let mut got_any = false;
loop {
let packet = capture.GetNextPacketSize()?;
if packet == 0 {
break;
}
got_any = true;
let mut pdata: *mut u8 = std::ptr::null_mut();
let mut nframes: u32 = 0;
let mut flags: u32 = 0;
capture.GetBuffer(&mut pdata, &mut nframes, &mut flags, None, None)?;
let nframes = nframes as usize;
if active() && nframes > 0 {
let silent = (flags & AUDCLNT_BUFFERFLAGS_SILENT.0 as u32) != 0;
if silent || pdata.is_null() {
if need_resample {
for _ in 0..nframes {
resampler.push(0.0, 0.0, &mut accum);
}
} else {
for _ in 0..nframes {
accum.push(0);
accum.push(0);
}
}
} else if need_resample {
for_each_stereo(pdata, nframes, channels, bits, is_float, |l, r| {
resampler.push(l, r, &mut accum);
});
} else {
for_each_stereo(pdata, nframes, channels, bits, is_float, |l, r| {
accum.push(to_i16(l));
accum.push(to_i16(r));
});
}
}
capture.ReleaseBuffer(nframes as u32)?;
}
// Emit whole 20 ms Opus frames.
while accum.len() >= frame_len {
match enc.encode(&accum[..frame_len], FRAME_SIZE) {
Ok(pkt) => on_packet(pkt),
Err(e) => log::warn!("audio: opus encode: {e}"),
}
accum.drain(..frame_len);
}
if !got_any {
// Silence / idle: nothing to drain, back off briefly.
std::thread::sleep(Duration::from_millis(10));
}
}
let _ = client.Stop();
Ok(())
}
}
/// Decode one WASAPI buffer (`nframes` frames of `channels` samples) to stereo
/// f32 pairs, calling `emit(l, r)` once per frame. Takes the first two channels
/// (duplicating channel 0 for mono); extra channels are ignored. Yielding f32
/// pairs lets the caller either quantise straight to i16 (native 48 kHz path) or
/// feed them through the resampler (non-48 kHz devices) without a second pass.
unsafe fn for_each_stereo(
pdata: *mut u8,
nframes: usize,
channels: usize,
bits: u16,
is_float: bool,
mut emit: impl FnMut(f32, f32),
) {
if is_float && bits == 32 {
let src = std::slice::from_raw_parts(pdata as *const f32, nframes * channels);
for f in 0..nframes {
let base = f * channels;
let l = src[base];
let r = if channels >= 2 { src[base + 1] } else { l };
emit(l, r);
}
} else if !is_float && bits == 16 {
let src = std::slice::from_raw_parts(pdata as *const i16, nframes * channels);
for f in 0..nframes {
let base = f * channels;
let l = src[base] as f32 / 32768.0;
let r = if channels >= 2 { src[base + 1] as f32 / 32768.0 } else { l };
emit(l, r);
}
} else if !is_float && bits == 32 {
// 32-bit integer PCM (some pro/USB interfaces present this).
let src = std::slice::from_raw_parts(pdata as *const i32, nframes * channels);
for f in 0..nframes {
let base = f * channels;
let l = src[base] as f32 / 2_147_483_648.0;
let r = if channels >= 2 { src[base + 1] as f32 / 2_147_483_648.0 } else { l };
emit(l, r);
}
} else if is_float && bits == 64 {
let src = std::slice::from_raw_parts(pdata as *const f64, nframes * channels);
for f in 0..nframes {
let base = f * channels;
let l = src[base] as f32;
let r = if channels >= 2 { src[base + 1] as f32 } else { l };
emit(l, r);
}
} else {
// Unknown bit depth: emit silence to keep timing rather than garbage.
for _ in 0..nframes {
emit(0.0, 0.0);
}
}
}
/// Minimal stateful linear resampler: stereo f32 in at `src_rate`, stereo i16 out
/// at `dst_rate`, appended to `out`. The phase and previous sample persist across
/// calls so consecutive WASAPI buffers resample seamlessly (no per-buffer clicks).
/// No anti-alias filter — but transparent for speech/UI/media at the 44100->48000
/// upsample that is by far the common non-48 kHz case.
struct LinearResampler {
step: f64, // source samples to advance per output sample (src/dst)
t: f64, // phase in [0,1) between prev and cur
prev_l: f32,
prev_r: f32,
primed: bool,
}
impl LinearResampler {
fn new(src_rate: u32, dst_rate: u32) -> Self {
Self {
step: src_rate as f64 / dst_rate as f64,
t: 0.0,
prev_l: 0.0,
prev_r: 0.0,
primed: false,
}
}
#[inline]
fn push(&mut self, cur_l: f32, cur_r: f32, out: &mut Vec<i16>) {
if !self.primed {
self.prev_l = cur_l;
self.prev_r = cur_r;
self.primed = true;
}
// Emit every output sample whose source position falls in [prev, cur).
while self.t < 1.0 {
let ft = self.t as f32;
out.push(to_i16(self.prev_l + (cur_l - self.prev_l) * ft));
out.push(to_i16(self.prev_r + (cur_r - self.prev_r) * ft));
self.t += self.step;
}
self.t -= 1.0;
self.prev_l = cur_l;
self.prev_r = cur_r;
}
}
#[inline]
fn to_i16(f: f32) -> i16 {
(f.clamp(-1.0, 1.0) * 32767.0) as i16
}
/// 200 ms shared-mode render buffer for voice playback.
const VOICE_BUFFER_HNS: i64 = 2_000_000;
/// Play the viewer's microphone (two-way voice call) on the host's default render
/// endpoint. Reads Opus packets from `rx`, decodes to 48 kHz stereo, converts to
/// the device mix format (resampling when the device isn't 48 kHz), and writes to
/// the shared-mode render buffer. Best-effort: any WASAPI/Opus error ends the
/// current attempt and re-opens; capture + video are unaffected.
pub fn run_voice_playback(stop: Arc<AtomicBool>, rx: std::sync::mpsc::Receiver<Vec<u8>>) {
unsafe {
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
}
while !stop.load(Ordering::Relaxed) {
match unsafe { voice_playback(&stop, &rx) } {
Ok(()) => break, // sender dropped / stop requested
Err(e) => {
log::warn!("voice: playback ended: {e} — re-opening");
std::thread::sleep(Duration::from_millis(400));
}
}
}
}
unsafe fn voice_playback(
stop: &AtomicBool,
rx: &std::sync::mpsc::Receiver<Vec<u8>>,
) -> anyhow::Result<()> {
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
let enumerator: IMMDeviceEnumerator =
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?;
let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole)?;
let client: IAudioClient = device.Activate(CLSCTX_ALL, None)?;
let mix = client.GetMixFormat()?;
let (rate, channels, bits, is_float) = format_params(mix);
let channels = channels as usize;
client.Initialize(AUDCLNT_SHAREMODE_SHARED, 0, VOICE_BUFFER_HNS, 0, mix, None)?;
CoTaskMemFree(Some(mix as *const _));
let buf_frames = client.GetBufferSize()?;
let render: IAudioRenderClient = client.GetService()?;
let mut dec = Decoder::new(SAMPLE_RATE, 2)?;
client.Start()?;
let need_resample = rate != SAMPLE_RATE;
let mut rs = LinearResampler::new(SAMPLE_RATE, rate.max(1));
// Decoded, device-rate, interleaved-stereo i16 awaiting the render buffer.
let mut pcm: VecDeque<i16> = VecDeque::with_capacity(buf_frames as usize * 2);
let mut scratch: Vec<i16> = Vec::new();
log::info!("voice: render ready ({rate} Hz, {channels}ch) — awaiting viewer mic");
let mut rx_pkts: u64 = 0;
let mut rx_peak: i32 = 0;
while !stop.load(Ordering::Relaxed) {
// Drain queued Opus packets -> PCM (bounded so a burst can't balloon RAM).
loop {
if pcm.len() > (rate as usize) * 2 {
break; // ~1 s already buffered; let it drain first
}
match rx.try_recv() {
Ok(opus) => {
if let Ok(frame) = dec.decode(&opus) {
rx_pkts += 1;
for &s in frame.iter().step_by(64) {
let a = (s as i32).abs();
if a > rx_peak { rx_peak = a; }
}
if rx_pkts % 250 == 0 {
log::info!("voice: rx {rx_pkts} pkts from viewer mic, recent peak {rx_peak}");
rx_peak = 0;
}
if need_resample {
scratch.clear();
let n = frame.len() / 2;
for i in 0..n {
let l = frame[i * 2] as f32 / 32768.0;
let r = frame[i * 2 + 1] as f32 / 32768.0;
rs.push(l, r, &mut scratch);
}
pcm.extend(scratch.iter().copied());
} else {
pcm.extend(frame.iter().copied());
}
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()),
}
}
// Write as much PCM as the render buffer currently has room for.
let padding = client.GetCurrentPadding()?;
let avail = buf_frames.saturating_sub(padding);
let have = (pcm.len() / 2) as u32;
let to_write = avail.min(have);
if to_write > 0 {
let data = render.GetBuffer(to_write)?;
write_render(data, to_write as usize, channels, bits, is_float, &mut pcm);
render.ReleaseBuffer(to_write, 0)?;
}
std::thread::sleep(Duration::from_millis(8));
}
let _ = client.Stop();
Ok(())
}
/// Write `nframes` of interleaved stereo i16 (popped from `pcm`) to a WASAPI
/// render buffer in the device's mix format. Channel 0=L, 1=R, extra = silence;
/// a mono device gets the L/R average.
unsafe fn write_render(
data: *mut u8,
nframes: usize,
channels: usize,
bits: u16,
is_float: bool,
pcm: &mut VecDeque<i16>,
) {
if is_float && bits == 32 {
let dst = std::slice::from_raw_parts_mut(data as *mut f32, nframes * channels);
for f in 0..nframes {
let l = pcm.pop_front().unwrap_or(0) as f32 / 32768.0;
let r = pcm.pop_front().unwrap_or(0) as f32 / 32768.0;
let base = f * channels;
if channels == 1 {
dst[base] = (l + r) * 0.5;
} else {
dst[base] = l;
dst[base + 1] = r;
for c in 2..channels {
dst[base + c] = 0.0;
}
}
}
} else if !is_float && bits == 16 {
let dst = std::slice::from_raw_parts_mut(data as *mut i16, nframes * channels);
for f in 0..nframes {
let l = pcm.pop_front().unwrap_or(0);
let r = pcm.pop_front().unwrap_or(0);
let base = f * channels;
if channels == 1 {
dst[base] = ((l as i32 + r as i32) / 2) as i16;
} else {
dst[base] = l;
dst[base + 1] = r;
for c in 2..channels {
dst[base + c] = 0;
}
}
}
} else {
// Unsupported mix format: consume + emit silence to keep the clock.
for _ in 0..nframes {
pcm.pop_front();
pcm.pop_front();
}
let bytes = nframes * channels * (bits as usize / 8).max(1);
let dst = std::slice::from_raw_parts_mut(data, bytes);
for b in dst.iter_mut() {
*b = 0;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Exercises the real WASAPI loopback init + drain/stop path on this machine
// (a Windows box with audio devices). Tolerant: a box with no render
// endpoint makes capture() bail early, which is still a clean return. The
// point is that init + stop never panic and the thread exits promptly.
// The non-48 kHz path that used to be a hard bail. Feed a 44100 Hz stereo
// sine through the resampler and prove it (a) yields ~48000/44100x as many
// output frames and (b) is not silent — i.e. real audio comes out, so a
// 44.1 kHz device now streams instead of being disabled.
#[test]
fn resampler_44100_to_48000_is_audible() {
let mut rs = LinearResampler::new(44100, 48000);
let mut out: Vec<i16> = Vec::new();
let n_in = 44100usize; // 1 second of source
for i in 0..n_in {
let t = i as f32 / 44100.0;
let s = (2.0 * std::f32::consts::PI * 440.0 * t).sin() * 0.8;
rs.push(s, s, &mut out);
}
let out_frames = out.len() / 2;
// Expect ~48000 output frames (±1%).
let expected = 48000i64;
let diff = (out_frames as i64 - expected).abs();
assert!(
diff < 480,
"resampled frame count {out_frames} not ~{expected} (diff {diff})"
);
let peak = out.iter().map(|&s| (s as i32).abs()).max().unwrap_or(0);
assert!(peak > 10_000, "resampled audio is silent (peak={peak})");
// Mono-fed => L and R must stay identical through the resampler.
assert!(
out.chunks(2).all(|c| c[0] == c[1]),
"resampler desynced L/R channels"
);
println!("RESAMPLER_44K_OK out_frames={out_frames} peak={peak}");
}
#[test]
fn loopback_inits_and_stops_cleanly() {
let stop = Arc::new(AtomicBool::new(false));
let got = Arc::new(std::sync::Mutex::new(0usize));
let s2 = stop.clone();
let g2 = got.clone();
let h = std::thread::spawn(move || {
run_loopback_opus(
s2,
|| true,
move |_pkt| {
*g2.lock().unwrap() += 1;
},
);
});
std::thread::sleep(Duration::from_millis(300));
stop.store(true, Ordering::Relaxed);
let start = std::time::Instant::now();
while !h.is_finished() && start.elapsed() < Duration::from_secs(3) {
std::thread::sleep(Duration::from_millis(20));
}
assert!(h.is_finished(), "audio capture thread did not stop within 3s");
h.join().unwrap();
println!("AUDIO_LOOPBACK_OK packets={}", *got.lock().unwrap());
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Encode correctness + honest timing on synthetic MOVING content (independent
//! of whether the live desktop is active). Encodes N frames, decodes them back
//! with the real viewer decoder, and asserts the roundtrip reproduces the right
//! dimensions — proving the Annex-B the host emits is valid for the viewer.
//!
//! Usage: netdesk-host-encbench [sw|mf] (default sw = openh264 software)
use std::time::Instant;
fn make_frame(w: usize, h: usize, t: usize) -> Vec<u8> {
let mut buf = vec![0u8; w * h * 4];
let bx = (t * 13) % (w.saturating_sub(120).max(1));
let by = (t * 7) % (h.saturating_sub(120).max(1));
for y in 0..h {
for x in 0..w {
let i = (y * w + x) * 4;
let inbox = x >= bx && x < bx + 120 && y >= by && y < by + 120;
if inbox {
buf[i] = 255;
buf[i + 1] = 255;
buf[i + 2] = 255;
buf[i + 3] = 255;
} else {
buf[i] = ((x + t * 3) & 0xff) as u8;
buf[i + 1] = ((y + t * 2) & 0xff) as u8;
buf[i + 2] = ((x + y + t) & 0xff) as u8;
buf[i + 3] = 255;
}
}
}
buf
}
/// Mean (R,G,B) of a decoded row.
fn row_means(rgb: &[u8], w: usize, row: usize) -> (f64, f64, f64) {
let base = row * w * 3;
let (mut r, mut g, mut b) = (0u64, 0u64, 0u64);
for col in 0..w {
let o = base + col * 3;
r += rgb[o] as u64;
g += rgb[o + 1] as u64;
b += rgb[o + 2] as u64;
}
let n = w as f64;
(r as f64 / n, g as f64 / n, b as f64 / n)
}
/// Solid gray frame (128,128,128) with a moving white box, so a correct decode
/// is gray everywhere and any green row stands out immediately.
fn gray_frame(w: usize, h: usize, t: usize) -> Vec<u8> {
let mut buf = vec![128u8; w * h * 4];
for p in buf.chunks_exact_mut(4) {
p[3] = 255;
}
let bx = (t * 11) % (w.saturating_sub(100).max(1));
for y in 0..100.min(h) {
for x in bx..(bx + 100).min(w) {
let i = (y * w + x) * 4;
buf[i] = 255;
buf[i + 1] = 255;
buf[i + 2] = 255;
}
}
buf
}
fn main() -> anyhow::Result<()> {
let mode = std::env::args().nth(1).unwrap_or_else(|| "sw".into());
let (w, h) = (1920usize, 1080usize);
let n = 120usize;
println!("encode roundtrip [{}]: {}x{}, {} frames", mode, w, h, n);
// Green-line hunt: QSV-encode gray frames, decode through the HARDWARE
// decoder (best_decoder), and scan every decoded row for anomalous green.
#[cfg(windows)]
if mode == "mfdec" {
let _ = tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).try_init();
let w = std::env::var("ND_W").ok().and_then(|s| s.parse().ok()).unwrap_or(w);
let h = std::env::var("ND_H").ok().and_then(|s| s.parse().ok()).unwrap_or(h);
println!(" mfdec dims: {}x{}", w, h);
let mut enc = netdesk_host::encode_mf::MfEncoder::new(w, h, 60, 8_000_000)?;
let mut dec = netdesk_core::decode::best_decoder();
println!("encoder: {}", enc.name());
let mut aus: Vec<Vec<u8>> = Vec::new();
for t in 0..30 {
for ef in enc.encode(&gray_frame(w, h, t))? {
aus.push(ef.data);
}
}
for ef in enc.drain()? {
aus.push(ef.data);
}
let mut frames_checked = 0usize;
let mut green_rows_total = 0usize;
let mut first_report = String::new();
for au in &aus {
if let Some(fr) = dec.decode(au)? {
frames_checked += 1;
let mut green_here = 0usize;
let mut first = None;
for row in 0..fr.h {
let (r, g, b) = row_means(&fr.rgb, fr.w, row);
// "green" = G clearly above both R and B (gray/white have R≈G≈B).
if g - r > 40.0 && g - b > 40.0 {
green_here += 1;
if first.is_none() {
first = Some((row, r, g, b));
}
}
}
green_rows_total += green_here;
if green_here > 0 && first_report.is_empty() {
let (row, r, g, b) = first.unwrap();
first_report = format!(
"frame {}x{}: {} green rows; first row {} rgb=({:.0},{:.0},{:.0})",
fr.w, fr.h, green_here, row, r, g, b
);
}
}
}
println!("decoded {} frames; total green rows: {}", frames_checked, green_rows_total);
if green_rows_total == 0 {
println!("NO GREEN LINES via hardware decode");
} else {
println!("GREEN DETECTED: {}", first_report);
}
return Ok(());
}
let mut dec = netdesk_core::decode::H264Decoder::new()?;
let mut keyframes = 0usize;
let mut decoded_ok = 0usize;
let mut total_bytes = 0usize;
let mut enc_ns = 0u128;
let mut emitted = 0usize;
// Collect all AUs (with timing) then decode, so the two encoders share one path.
let mut aus: Vec<(Vec<u8>, bool)> = Vec::new();
#[cfg(windows)]
if mode == "mf" {
let mut enc = match netdesk_host::encode_mf::MfEncoder::new(w, h, 60, 8_000_000) {
Ok(e) => e,
Err(e) => {
println!("no hardware encoder ({e}); skipping mf test");
return Ok(());
}
};
println!("hardware encoder: {} (hw={})", enc.name(), enc.is_hardware());
for t in 0..n {
let frame = make_frame(w, h, t);
let t0 = Instant::now();
let outs = enc.encode(&frame)?;
enc_ns += t0.elapsed().as_nanos();
for ef in outs {
aus.push((ef.data, ef.keyframe));
}
}
for ef in enc.drain()? {
aus.push((ef.data, ef.keyframe));
}
}
if mode != "mf" {
let mut enc = netdesk_host::encode::H264Encoder::new(w, h, 60, 8_000_000)?;
for t in 0..n {
let frame = make_frame(w, h, t);
let t0 = Instant::now();
let out = enc.encode(&frame)?;
enc_ns += t0.elapsed().as_nanos();
if let Some(ef) = out {
aus.push((ef.data, ef.keyframe));
}
}
}
for (data, keyframe) in &aus {
emitted += 1;
total_bytes += data.len();
if *keyframe {
keyframes += 1;
}
if let Some(fr) = dec.decode(data)? {
if fr.w == w && fr.h == h {
decoded_ok += 1;
}
}
}
let enc_ms = enc_ns as f64 / n as f64 / 1e6;
println!(" encode avg: {:.2} ms/frame (~{:.0} fps)", enc_ms, 1000.0 / enc_ms);
println!(" AUs emitted: {} keyframes: {} decoded_ok: {}", emitted, keyframes, decoded_ok);
println!(" avg AU: {:.1} KB", total_bytes as f64 / emitted.max(1) as f64 / 1024.0);
if keyframes == 0 {
anyhow::bail!("no keyframe emitted");
}
if decoded_ok < emitted.saturating_sub(2) {
anyhow::bail!("roundtrip decode failed: {}/{} decoded", decoded_ok, emitted);
}
println!("ROUNDTRIP OK");
Ok(())
}
+76
View File
@@ -0,0 +1,76 @@
//! Standalone NetDesk native host engine. Captures the screen and serves
//! viewers over the NetDesk protocol with the best available H.264 encoder and
//! SendInput injection. Configured via environment:
//! ND_HOST_ID (required) the id viewers connect to
//! ND_PASSWORD (required) session password (never leaves this machine)
//! ND_WS_URL default wss://netdesk.space/ws
//! ND_NAME default "NetDesk (Rust)"
//! ND_MONITOR default 0
//! ND_FPS default 60
//! ND_BITRATE default 8000000
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
#[cfg(windows)]
{
let host_id = std::env::var("ND_HOST_ID").map_err(|_| anyhow::anyhow!("set ND_HOST_ID"))?;
let password =
std::env::var("ND_PASSWORD").map_err(|_| anyhow::anyhow!("set ND_PASSWORD"))?;
let cfg = netdesk_host::run::EngineConfig {
ws_url: std::env::var("ND_WS_URL").unwrap_or_else(|_| "wss://netdesk.space/ws".into()),
host_id,
name: std::env::var("ND_NAME").unwrap_or_else(|_| "NetDesk (Rust)".into()),
password,
monitor: envnum("ND_MONITOR", 0),
fps: envnum("ND_FPS", 60),
bitrate: envnum("ND_BITRATE", 8_000_000),
// Host-enforced permissions + approve-before-connect (see run.rs):
// ND_PERM_INPUT / ND_PERM_CLIPBOARD / ND_PERM_FILES / ND_PERM_AUDIO
// (default 1) and ND_APPROVE (default 0).
perms: netdesk_core::host::HostPerms {
input: envflag("ND_PERM_INPUT", true),
clipboard: envflag("ND_PERM_CLIPBOARD", true),
files: envflag("ND_PERM_FILES", true),
audio: envflag("ND_PERM_AUDIO", true),
},
approve: envflag("ND_APPROVE", false),
totp_secret: std::env::var("ND_TOTP").unwrap_or_default(),
// Encrypted direct-IP: ND_DIRECT_PORT makes the host listen on that
// TCP port for a viewer that dials it directly (no relay);
// ND_ALLOW_IPS is an optional CIDR allow-list.
direct_port: std::env::var("ND_DIRECT_PORT").ok().and_then(|s| s.parse().ok()),
allow_ips: std::env::var("ND_ALLOW_IPS").unwrap_or_default(),
// ND_MAX_VIEWERS>1 allows multiple simultaneous direct-IP viewers
// (1 controller + N observers of the one capture).
max_viewers: std::env::var("ND_MAX_VIEWERS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1)
.max(1),
};
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let handle = netdesk_host::run::EngineHandle::new();
rt.block_on(netdesk_host::run::run_engine(cfg, handle))?;
}
#[cfg(not(windows))]
println!("engine is Windows-only");
Ok(())
}
#[cfg(windows)]
fn envnum<T: std::str::FromStr>(key: &str, default: T) -> T {
std::env::var(key).ok().and_then(|s| s.parse().ok()).unwrap_or(default)
}
/// "1"/"true"/"yes" = true, "0"/"false"/"no" = false, unset = `default`.
#[cfg(windows)]
fn envflag(key: &str, default: bool) -> bool {
match std::env::var(key).as_deref().map(str::trim) {
Ok("1") | Ok("true") | Ok("yes") => true,
Ok("0") | Ok("false") | Ok("no") => false,
_ => default,
}
}
+146
View File
@@ -0,0 +1,146 @@
//! Privacy-mode feasibility probe. Determines whether a window marked
//! `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` is hidden from THIS host's
//! own screen capture — both DXGI Desktop Duplication (`grab`) and the GDI
//! BitBlt fallback (`grab_seed`). If it is, a fullscreen black excluded window
//! is a real, driver-free privacy mode: the local monitor shows black while the
//! remote operator still captures the desktop behind it.
//!
//! Method: show a small solid-magenta popup at a known spot, capture with the
//! affinity OFF (control — magenta must appear) then ON (magenta must vanish),
//! and count magenta pixels in each frame. Safe: the window is small and shown
//! for ~2s, then destroyed. Developed by Akronlogic.
#![cfg(windows)]
use std::time::{Duration, Instant};
use windows::core::w;
use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::Graphics::Gdi::CreateSolidBrush;
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, PeekMessageW, RegisterClassW,
SetWindowDisplayAffinity, ShowWindow, TranslateMessage, MSG, PM_REMOVE, SW_SHOW,
WDA_EXCLUDEFROMCAPTURE, WDA_NONE, WNDCLASSW, WS_EX_TOPMOST, WS_POPUP, WS_VISIBLE,
};
use netdesk_host::capture::DxgiCapturer;
unsafe extern "system" fn wndproc(h: HWND, m: u32, w: WPARAM, l: LPARAM) -> LRESULT {
DefWindowProcW(h, m, w, l)
}
fn pump(ms: u64) {
let end = Instant::now() + Duration::from_millis(ms);
while Instant::now() < end {
unsafe {
let mut msg = MSG::default();
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
std::thread::sleep(Duration::from_millis(15));
}
}
/// Count magenta (B=255,G=0,R=255 with tolerance) pixels in a BGRA buffer.
fn count_magenta(buf: &[u8]) -> usize {
buf.chunks_exact(4)
.filter(|p| p[0] >= 250 && p[1] <= 6 && p[2] >= 250)
.count()
}
/// Freshest DXGI frame over `ms` (None if the duplication reported no change).
fn freshest_dxgi(cap: &mut DxgiCapturer, ms: u64) -> Option<Vec<u8>> {
let end = Instant::now() + Duration::from_millis(ms);
let mut last = None;
while Instant::now() < end {
if let Ok(Some(f)) = cap.grab(80) {
last = Some(f);
}
}
last
}
fn main() {
unsafe {
let hinst = HINSTANCE::default();
let brush = CreateSolidBrush(COLORREF(0x00FF_00FF)); // B=FF,G=00,R=FF = magenta
let class = w!("NdPrivacyProbe");
let wc = WNDCLASSW {
lpfnWndProc: Some(wndproc),
hInstance: hinst,
lpszClassName: class,
hbrBackground: brush,
..Default::default()
};
let atom = RegisterClassW(&wc);
if atom == 0 {
eprintln!("RegisterClassW failed");
std::process::exit(1);
}
let hwnd = match CreateWindowExW(
WS_EX_TOPMOST,
class,
w!("nd-privacy-probe"),
WS_POPUP | WS_VISIBLE,
120,
120,
520,
380,
None,
None,
hinst,
None,
) {
Ok(h) => h,
Err(e) => {
eprintln!("CreateWindowExW failed: {e}");
std::process::exit(1);
}
};
let _ = ShowWindow(hwnd, SW_SHOW);
pump(400);
let mut cap = match DxgiCapturer::new(0) {
Ok(c) => c,
Err(e) => {
eprintln!("DxgiCapturer::new failed: {e}");
let _ = DestroyWindow(hwnd);
std::process::exit(1);
}
};
// CONTROL: affinity OFF — the window must be visible to both captures.
let _ = SetWindowDisplayAffinity(hwnd, WDA_NONE);
pump(300);
let ctl_gdi = cap.grab_seed().map(|b| count_magenta(&b)).unwrap_or(usize::MAX);
let ctl_dxgi = freshest_dxgi(&mut cap, 700).map(|b| count_magenta(&b));
// EXCLUDE: affinity ON — the window must vanish from capture.
let _ = SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE);
pump(400);
let exc_gdi = cap.grab_seed().map(|b| count_magenta(&b)).unwrap_or(usize::MAX);
let exc_dxgi = freshest_dxgi(&mut cap, 900).map(|b| count_magenta(&b));
let _ = DestroyWindow(hwnd);
println!("PRIVACY_PROBE control_gdi_magenta={ctl_gdi} exclude_gdi_magenta={exc_gdi}");
println!(
"PRIVACY_PROBE control_dxgi_magenta={:?} exclude_dxgi_magenta={:?}",
ctl_dxgi, exc_dxgi
);
let gdi_ok = ctl_gdi > 1000 && exc_gdi == 0;
// DXGI: control should show magenta; exclude should show none. If the
// duplication reported no change in a phase (None), note it.
let dxgi_ok = matches!((ctl_dxgi, exc_dxgi), (Some(c), Some(e)) if c > 1000 && e == 0);
if gdi_ok && dxgi_ok {
println!("VERDICT: PRIVACY_WDA_WORKS — WDA_EXCLUDEFROMCAPTURE hides the window from BOTH DXGI and GDI capture");
} else if gdi_ok {
println!("VERDICT: PRIVACY_WDA_GDI_ONLY — GDI excludes it; DXGI result {:?}/{:?} (inconclusive or not excluded)", ctl_dxgi, exc_dxgi);
} else {
println!("VERDICT: PRIVACY_WDA_FAILS — the window was still captured; privacy needs an IDD virtual display");
}
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Capture + encode benchmark: open DXGI duplication on monitor 0, encode each
//! frame to H.264, and report per-stage timing + bitstream sizes. Proves the
//! native capture->encode pipeline works and shows where we stand on latency.
#[cfg(windows)]
use std::time::Instant;
fn main() -> anyhow::Result<()> {
#[cfg(windows)]
{
netdesk_host::set_dpi_aware();
let mut cap = netdesk_host::capture::DxgiCapturer::new(0)?;
let (w, h) = (cap.width() as usize, cap.height() as usize);
println!("capture: {}x{}", w, h);
let mut enc = netdesk_host::encode::H264Encoder::new(w, h, 60, 8_000_000)?;
let mut last: Option<Vec<u8>> = None;
let mut frames = 0usize;
let mut keyframes = 0usize;
let mut cap_ns = 0u128;
let mut enc_ns = 0u128;
let mut total_bytes = 0usize;
let mut encoded = 0usize;
// warm up + run ~150 iterations
for i in 0..150 {
let t0 = Instant::now();
let frame = match cap.grab(50)? {
Some(buf) => {
last = Some(buf);
last.as_ref().unwrap()
}
None => match last.as_ref() {
Some(b) => b, // screen unchanged: re-encode last (keeps fps up)
None => continue,
},
};
let t1 = Instant::now();
let out = enc.encode(frame)?;
let t2 = Instant::now();
if i >= 10 {
// skip warmup
frames += 1;
cap_ns += (t1 - t0).as_nanos();
enc_ns += (t2 - t1).as_nanos();
if let Some(ef) = &out {
encoded += 1;
total_bytes += ef.data.len();
if ef.keyframe {
keyframes += 1;
}
}
}
}
if frames == 0 {
anyhow::bail!("no frames");
}
let cap_ms = cap_ns as f64 / frames as f64 / 1e6;
let enc_ms = enc_ns as f64 / frames as f64 / 1e6;
println!("measured over {} frames ({} produced output, {} keyframes)", frames, encoded, keyframes);
println!(" capture avg: {:.2} ms", cap_ms);
println!(" encode avg: {:.2} ms", enc_ms);
println!(" pipeline: {:.2} ms (~{:.0} fps ceiling)", cap_ms + enc_ms, 1000.0 / (cap_ms + enc_ms));
if encoded > 0 {
let avg_kb = total_bytes as f64 / encoded as f64 / 1024.0;
println!(" avg AU size: {:.1} KB (~{:.1} Mbit/s @60fps)", avg_kb, avg_kb * 1024.0 * 8.0 * 60.0 / 1e6);
}
}
#[cfg(not(windows))]
println!("probe is Windows-only");
Ok(())
}
+489
View File
@@ -0,0 +1,489 @@
//! Windows screen capture via DXGI Desktop Duplication — the same GPU path
//! dxcam / RustDesk use: the OS hands us the latest desktop frame as a GPU
//! texture, we copy it to a CPU-readable staging texture and read BGRA out.
//! `grab` returns `None` when the desktop is unchanged (no new frame), so an
//! idle screen costs almost nothing.
use anyhow::{anyhow, Result};
use windows::core::Interface;
use windows::Win32::Foundation::{HMODULE, E_ACCESSDENIED};
use windows::Win32::Graphics::Direct3D::{
D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0,
};
use windows::Win32::Graphics::Direct3D11::{
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_FLAG, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ,
D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING,
};
use windows::Win32::Graphics::Dxgi::Common::DXGI_SAMPLE_DESC;
use windows::Win32::Graphics::Dxgi::{
IDXGIAdapter, IDXGIDevice, IDXGIOutput, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource,
DXGI_ERROR_ACCESS_LOST, DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_FRAME_INFO,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
SM_YVIRTUALSCREEN,
};
/// Geometry of one display output (flat DXGI output index + its desktop rect).
#[derive(Clone, Copy, Debug)]
pub struct MonitorDesc {
pub index: u32,
pub width: u32,
pub height: u32,
pub left: i32,
pub top: i32,
}
/// A live Desktop Duplication session for one monitor.
pub struct DxgiCapturer {
device: ID3D11Device,
context: ID3D11DeviceContext,
dupl: IDXGIOutputDuplication,
width: u32,
height: u32,
left: i32,
top: i32,
staging: Option<ID3D11Texture2D>,
}
impl DxgiCapturer {
/// Enumerate all display outputs on the default adapter, in the same flat
/// index order `new(index)` uses. Best-effort: returns an empty vec on any
/// failure (callers fall back to a single monitor). The index is what the
/// host advertises as `monitors[].i` and what a `monitor.switch{mon}` sends.
pub fn enumerate() -> Vec<MonitorDesc> {
let mut out = Vec::new();
unsafe {
let mut device: Option<ID3D11Device> = None;
let levels = [D3D_FEATURE_LEVEL_11_0];
if D3D11CreateDevice(
None,
D3D_DRIVER_TYPE_HARDWARE,
HMODULE::default(),
D3D11_CREATE_DEVICE_FLAG(0),
Some(&levels),
D3D11_SDK_VERSION,
Some(&mut device),
None,
None,
)
.is_err()
{
return out;
}
let device = match device {
Some(d) => d,
None => return out,
};
let dxgi_device: IDXGIDevice = match device.cast() {
Ok(d) => d,
Err(_) => return out,
};
let adapter: IDXGIAdapter = match dxgi_device.GetAdapter() {
Ok(a) => a,
Err(_) => return out,
};
let mut i = 0u32;
loop {
match adapter.EnumOutputs(i) {
Ok(output) => {
if let Ok(desc) = output.GetDesc() {
let c = desc.DesktopCoordinates;
out.push(MonitorDesc {
index: i,
width: (c.right - c.left) as u32,
height: (c.bottom - c.top) as u32,
left: c.left,
top: c.top,
});
}
i += 1;
}
Err(_) => break, // DXGI_ERROR_NOT_FOUND: no more outputs
}
}
}
out
}
/// Open duplication for `output_index` (0 = first monitor).
pub fn new(output_index: u32) -> Result<Self> {
unsafe {
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
let levels = [D3D_FEATURE_LEVEL_11_0];
D3D11CreateDevice(
None,
D3D_DRIVER_TYPE_HARDWARE,
HMODULE::default(),
D3D11_CREATE_DEVICE_FLAG(0),
Some(&levels),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)?;
let device = device.ok_or_else(|| anyhow!("D3D11 device is null"))?;
let context = context.ok_or_else(|| anyhow!("D3D11 context is null"))?;
let dxgi_device: IDXGIDevice = device.cast()?;
let adapter: IDXGIAdapter = dxgi_device.GetAdapter()?;
let output: IDXGIOutput = adapter.EnumOutputs(output_index)?;
let desc = output.GetDesc()?;
let width = (desc.DesktopCoordinates.right - desc.DesktopCoordinates.left) as u32;
let height = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top) as u32;
let left = desc.DesktopCoordinates.left;
let top = desc.DesktopCoordinates.top;
let output1: IDXGIOutput1 = output.cast()?;
let dupl = output1.DuplicateOutput(&device)?;
Ok(Self {
device,
context,
dupl,
width,
height,
left,
top,
staging: None,
})
}
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
/// Top-left of this monitor in virtual-desktop coordinates (for mapping the
/// global cursor position into frame-pixel space).
pub fn origin(&self) -> (i32, i32) {
(self.left, self.top)
}
/// Grab the next frame as tightly-packed BGRA (`width*height*4`). Returns
/// `Ok(None)` when nothing changed within `timeout_ms`. On a desktop switch
/// the duplication is lost (`access lost`) and the caller re-creates us.
pub fn grab(&mut self, timeout_ms: u32) -> Result<Option<Vec<u8>>> {
unsafe {
let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
let mut resource: Option<IDXGIResource> = None;
match self.dupl.AcquireNextFrame(timeout_ms, &mut info, &mut resource) {
Ok(()) => {}
Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => return Ok(None),
Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST || e.code() == E_ACCESSDENIED => {
return Err(anyhow!("duplication access lost"));
}
Err(e) => return Err(e.into()),
}
let resource = resource.ok_or_else(|| anyhow!("acquired null resource"))?;
let tex: ID3D11Texture2D = resource.cast()?;
let mut tdesc = D3D11_TEXTURE2D_DESC::default();
tex.GetDesc(&mut tdesc);
if self.staging.is_none() {
let sdesc = D3D11_TEXTURE2D_DESC {
Width: tdesc.Width,
Height: tdesc.Height,
MipLevels: 1,
ArraySize: 1,
Format: tdesc.Format,
SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 },
Usage: D3D11_USAGE_STAGING,
BindFlags: 0,
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
MiscFlags: 0,
};
let mut st: Option<ID3D11Texture2D> = None;
self.device.CreateTexture2D(&sdesc, None, Some(&mut st))?;
self.staging = st;
}
let staging = self.staging.clone().unwrap();
self.context.CopyResource(&staging, &tex);
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
self.context
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?;
let w = tdesc.Width as usize;
let h = tdesc.Height as usize;
let pitch = mapped.RowPitch as usize;
let src = mapped.pData as *const u8;
let mut out = vec![0u8; w * h * 4];
for y in 0..h {
std::ptr::copy_nonoverlapping(
src.add(y * pitch),
out.as_mut_ptr().add(y * w * 4),
w * 4,
);
}
self.context.Unmap(&staging, 0);
let _ = self.dupl.ReleaseFrame();
Ok(Some(out))
}
}
/// Return the CURRENT desktop image even when nothing has changed. A
/// long-running duplication yields `WAIT_TIMEOUT` on a static screen (so
/// `grab` returns `Ok(None)` indefinitely), which would leave a freshly
/// connected viewer black because the keyframe flush has no pixels to
/// encode. This gives DXGI a brief chance to produce the frame and otherwise
/// falls back to a GDI BitBlt of the monitor rect, which always returns live
/// pixels. Used only to seed a keyframe (viewer join / refresh), never in
/// the per-frame hot path.
pub fn grab_seed(&mut self) -> Result<Vec<u8>> {
for _ in 0..2 {
match self.grab(150) {
Ok(Some(buf)) => return Ok(buf),
Ok(None) => continue,
Err(e) => return Err(e),
}
}
gdi_capture(self.left, self.top, self.width, self.height)
}
}
impl Drop for DxgiCapturer {
fn drop(&mut self) {
unsafe {
let _ = self.dupl.ReleaseFrame();
}
}
}
/// Capture a monitor rect via GDI BitBlt as top-down BGRA. Unlike DXGI this
/// always returns the CURRENT pixels (no "a frame changed" requirement), so it
/// can seed the first keyframe for an idle/static screen. The host is
/// per-monitor-DPI-aware, so `left/top/width/height` are physical virtual-
/// desktop pixels that match the DXGI frame layout (BGRA, 4 bytes/pixel).
pub(crate) fn gdi_capture(left: i32, top: i32, width: u32, height: u32) -> Result<Vec<u8>> {
use windows::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC,
GetDIBits, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS,
HGDIOBJ, SRCCOPY,
};
let (w, h) = (width as i32, height as i32);
unsafe {
let screen = GetDC(None);
if screen.is_invalid() {
return Err(anyhow!("GetDC(screen) failed"));
}
let mem = CreateCompatibleDC(screen);
let bmp = CreateCompatibleBitmap(screen, w, h);
if mem.is_invalid() || bmp.is_invalid() {
ReleaseDC(None, screen);
return Err(anyhow!("GDI DC/bitmap alloc failed"));
}
let old = SelectObject(mem, HGDIOBJ(bmp.0));
let blt = BitBlt(mem, 0, 0, w, h, screen, left, top, SRCCOPY);
let mut bi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: w,
biHeight: -h, // negative => top-down, matching the DXGI buffer
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0 as u32,
..Default::default()
},
..Default::default()
};
let mut out = vec![0u8; (width * height * 4) as usize];
let lines = GetDIBits(
mem,
bmp,
0,
height,
Some(out.as_mut_ptr() as *mut core::ffi::c_void),
&mut bi,
DIB_RGB_COLORS,
);
let _ = SelectObject(mem, old);
let _ = DeleteObject(HGDIOBJ(bmp.0));
let _ = DeleteDC(mem);
ReleaseDC(None, screen);
if blt.is_err() || lines == 0 {
return Err(anyhow!("GDI BitBlt/GetDIBits failed"));
}
Ok(out)
}
}
/// Attach the calling (capture) thread to the desktop currently receiving input
/// — the normal Default desktop, or the secure Winlogon desktop when the machine
/// is locked, at the sign-in screen, or showing a UAC prompt. Running as SYSTEM
/// (via the service), this lets GDI capture + SendInput reach the secure desktop
/// so a remote viewer can sign in and unlock. Keeps exactly one desktop handle
/// open, closing the previous one on each switch.
static mut PREV_DESK: isize = 0;
pub(crate) fn attach_input_desktop() -> bool {
use windows::Win32::System::StationsAndDesktops::{
CloseDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS,
DESKTOP_CONTROL_FLAGS, HDESK,
};
unsafe {
// GENERIC_ALL (0x10000000) → the kernel maps it to all desktop rights.
match OpenInputDesktop(DESKTOP_CONTROL_FLAGS(0), false, DESKTOP_ACCESS_FLAGS(0x1000_0000)) {
Ok(h) => {
if SetThreadDesktop(h).is_ok() {
if PREV_DESK != 0 {
let _ = CloseDesktop(HDESK(PREV_DESK as *mut core::ffi::c_void));
}
PREV_DESK = h.0 as isize;
true
} else {
let _ = CloseDesktop(h);
false
}
}
Err(_) => false,
}
}
}
/// The all-monitors sentinel: `Capturer::open(MON_ALL)` captures the whole
/// virtual desktop (every monitor stitched) via GDI. On the wire it is
/// advertised + switched as monitor index `-1`.
pub const MON_ALL: u32 = u32::MAX;
/// Bounding rect of the whole virtual desktop (all monitors), physical pixels:
/// `(left, top, width, height)`. Width/height are rounded DOWN to even (the
/// encoder needs even dimensions).
pub fn virtual_desktop_rect() -> (i32, i32, u32, u32) {
unsafe {
let left = GetSystemMetrics(SM_XVIRTUALSCREEN);
let top = GetSystemMetrics(SM_YVIRTUALSCREEN);
let w = (GetSystemMetrics(SM_CXVIRTUALSCREEN).max(0) as u32) & !1;
let h = (GetSystemMetrics(SM_CYVIRTUALSCREEN).max(0) as u32) & !1;
(left, top, w, h)
}
}
/// All-monitors capturer: a GDI BitBlt of the whole virtual desktop each grab.
/// Slower than DXGI (no GPU path, no "changed-only" wait), so it is an opt-in
/// overview source, not the per-monitor default. Always returns live pixels.
pub struct VirtualGdiCapturer {
left: i32,
top: i32,
width: u32,
height: u32,
}
impl VirtualGdiCapturer {
pub fn new() -> Result<Self> {
let (left, top, width, height) = virtual_desktop_rect();
if width == 0 || height == 0 {
return Err(anyhow!("virtual desktop has zero size"));
}
Ok(Self { left, top, width, height })
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn origin(&self) -> (i32, i32) {
(self.left, self.top)
}
/// GDI has no "unchanged" signal, so every grab returns the current frame.
pub fn grab(&mut self, _timeout_ms: u32) -> Result<Option<Vec<u8>>> {
Ok(Some(gdi_capture(self.left, self.top, self.width, self.height)?))
}
pub fn grab_seed(&mut self) -> Result<Vec<u8>> {
gdi_capture(self.left, self.top, self.width, self.height)
}
}
/// A capture source: one DXGI-duplicated monitor, or the whole virtual desktop
/// (GDI). The capture loop holds this and never branches on the variant — the
/// enum delegates grab/seed/geometry, so `MON_ALL` "just works" as a monitor.
pub enum Capturer {
Dxgi(DxgiCapturer),
VirtualAll(VirtualGdiCapturer),
}
impl Capturer {
/// Open monitor `mon` (`MON_ALL` => the whole virtual desktop via GDI).
pub fn open(mon: u32) -> Result<Self> {
if mon == MON_ALL {
Ok(Capturer::VirtualAll(VirtualGdiCapturer::new()?))
} else {
Ok(Capturer::Dxgi(DxgiCapturer::new(mon)?))
}
}
pub fn grab(&mut self, timeout_ms: u32) -> Result<Option<Vec<u8>>> {
match self {
Capturer::Dxgi(c) => c.grab(timeout_ms),
Capturer::VirtualAll(c) => c.grab(timeout_ms),
}
}
pub fn grab_seed(&mut self) -> Result<Vec<u8>> {
match self {
Capturer::Dxgi(c) => c.grab_seed(),
Capturer::VirtualAll(c) => c.grab_seed(),
}
}
pub fn width(&self) -> u32 {
match self {
Capturer::Dxgi(c) => c.width(),
Capturer::VirtualAll(c) => c.width(),
}
}
pub fn height(&self) -> u32 {
match self {
Capturer::Dxgi(c) => c.height(),
Capturer::VirtualAll(c) => c.height(),
}
}
pub fn origin(&self) -> (i32, i32) {
match self {
Capturer::Dxgi(c) => c.origin(),
Capturer::VirtualAll(c) => c.origin(),
}
}
}
#[cfg(test)]
mod gdi_seed_tests {
use super::*;
/// The GDI fallback must return a full-size buffer of live (non-black)
/// desktop pixels — this is what rescues an idle screen from a black
/// keyframe. Runs only with a real desktop session.
#[test]
fn gdi_capture_returns_live_pixels() {
// A small rect at the virtual-desktop origin; every machine has pixels
// here (taskbar / wallpaper), so a correct capture is never all-black.
let w = 64u32;
let h = 64u32;
match gdi_capture(0, 0, w, h) {
Ok(buf) => {
assert_eq!(buf.len(), (w * h * 4) as usize, "wrong buffer size");
let lit = buf.chunks_exact(4).filter(|p| p[0] | p[1] | p[2] != 0).count();
assert!(lit > 0, "GDI capture returned an all-black buffer");
}
Err(e) => panic!("gdi_capture failed: {e}"),
}
}
/// The all-monitors capturer reports an even-sized virtual desktop and
/// grabs a full-size live frame. Runs only with a real desktop session.
#[test]
fn virtual_capturer_grabs_full_desktop() {
let (_l, _t, vw, vh) = virtual_desktop_rect();
assert!(vw > 0 && vh > 0, "virtual desktop size is zero");
assert!(vw % 2 == 0 && vh % 2 == 0, "virtual desktop size must be even");
let mut cap = Capturer::open(MON_ALL).expect("open all-monitors capturer");
assert_eq!((cap.width(), cap.height()), (vw, vh));
let buf = cap.grab_seed().expect("grab virtual desktop");
assert_eq!(buf.len(), (vw * vh * 4) as usize, "wrong buffer size");
let lit = buf.chunks_exact(4).filter(|p| p[0] | p[1] | p[2] != 0).count();
assert!(lit > 0, "all-monitors capture was all black");
println!("VIRTUAL_CAP_OK {}x{} bytes={}", vw, vh, buf.len());
}
}
+99
View File
@@ -0,0 +1,99 @@
//! BGRA -> NV12 color conversion (BT.601 limited range), the input most
//! hardware H.264 encoders want. We sample chroma at the top-left of each 2x2
//! block (fast; chroma is half-res anyway). Integer coefficients match the
//! viewer decoder's NV12->RGB (BT.601 limited) so a capture->encode->decode
//! roundtrip is color-stable.
//!
//! Output is tightly packed NV12: Y plane (w*h) then interleaved UV (w*h/2),
//! total w*h*3/2 bytes, stride == width (callers keep width 2-aligned).
/// Convert a tightly-packed BGRA frame (`w*h*4`) into `dst` as NV12. `dst` must
/// be at least `w*h*3/2` bytes. `w` and `h` must be even.
pub fn bgra_to_nv12(bgra: &[u8], w: usize, h: usize, dst: &mut [u8]) {
debug_assert!(w % 2 == 0 && h % 2 == 0);
debug_assert!(bgra.len() >= w * h * 4);
debug_assert!(dst.len() >= w * h * 3 / 2);
let (y_plane, uv_plane) = dst.split_at_mut(w * h);
// Luma: every pixel.
for y in 0..h {
let row = y * w;
for x in 0..w {
let p = (row + x) * 4;
let b = bgra[p] as i32;
let g = bgra[p + 1] as i32;
let r = bgra[p + 2] as i32;
// Y = 16 + (66R + 129G + 25B)/256
let yy = (66 * r + 129 * g + 25 * b + 128) >> 8;
y_plane[row + x] = (yy + 16).clamp(0, 255) as u8;
}
}
// Chroma: one U,V per 2x2 block, sampled at the block's top-left pixel.
let cw = w / 2;
for by in 0..h / 2 {
let srow = (by * 2) * w;
let drow = by * cw * 2;
for bx in 0..cw {
let p = (srow + bx * 2) * 4;
let b = bgra[p] as i32;
let g = bgra[p + 1] as i32;
let r = bgra[p + 2] as i32;
// U = 128 + (-38R - 74G + 112B)/256 ; V = 128 + (112R - 94G - 18B)/256
let u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
let v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
uv_plane[drow + bx * 2] = u.clamp(0, 255) as u8;
uv_plane[drow + bx * 2 + 1] = v.clamp(0, 255) as u8;
}
}
}
/// Bilinear downscale of a tightly-packed BGRA image `sw`x`sh` into `dst`
/// (`dw`x`dh`, also tightly packed BGRA). Used for the quality "scale" lever so
/// a constrained link can trade resolution for bandwidth. Only called when
/// dw<sw (downscale), where bilinear is a good quality/speed trade-off.
pub fn resize_bgra(src: &[u8], sw: usize, sh: usize, dst: &mut [u8], dw: usize, dh: usize) {
if dw == 0 || dh == 0 || sw == 0 || sh == 0 {
return;
}
let x_ratio = sw as f32 / dw as f32;
let y_ratio = sh as f32 / dh as f32;
for dy in 0..dh {
let sy = (dy as f32 + 0.5) * y_ratio - 0.5;
let sy = sy.clamp(0.0, (sh - 1) as f32);
let y0 = sy.floor() as usize;
let y1 = (y0 + 1).min(sh - 1);
let fy = sy - y0 as f32;
let r0 = y0 * sw * 4;
let r1 = y1 * sw * 4;
let drow = dy * dw * 4;
for dx in 0..dw {
let sx = (dx as f32 + 0.5) * x_ratio - 0.5;
let sx = sx.clamp(0.0, (sw - 1) as f32);
let x0 = sx.floor() as usize;
let x1 = (x0 + 1).min(sw - 1);
let fx = sx - x0 as f32;
let o = drow + dx * 4;
let (a, b, c, d) = (r0 + x0 * 4, r0 + x1 * 4, r1 + x0 * 4, r1 + x1 * 4);
for ch in 0..4 {
let p00 = src[a + ch] as f32;
let p01 = src[b + ch] as f32;
let p10 = src[c + ch] as f32;
let p11 = src[d + ch] as f32;
let top = p00 + (p01 - p00) * fx;
let bot = p10 + (p11 - p10) * fx;
dst[o + ch] = (top + (bot - top) * fy).round().clamp(0.0, 255.0) as u8;
}
}
}
}
/// Target encode dimensions for a capture `w`x`h` at `scale` (0..1], rounded to
/// even and clamped to a sane minimum.
pub fn scaled_even(w: u32, h: u32, scale: f32) -> (u32, u32) {
let s = scale.clamp(0.1, 1.0);
let sw = (((w as f32 * s) as u32) & !1).max(64).min(w);
let sh = (((h as f32 * s) as u32) & !1).max(64).min(h);
(sw, sh)
}
+282
View File
@@ -0,0 +1,282 @@
//! Remote cursor tracking (RustDesk-style overlay): report the host cursor's
//! position and shape to the viewer, sent on the control channel separately
//! from video so an idle screen still shows a live, correctly-shaped cursor.
//!
//! Shapes are reported as CSS names for the standard system cursors (arrow,
//! text, hand, resize, …) — which the viewer renders as crisp OS-native
//! pointers — so there is no fragile per-frame bitmap extraction. Non-standard
//! (app-custom) cursors fall back to the viewer's default pointer.
#![cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::{
GetCursorInfo, LoadCursorW, CURSORINFO, CURSOR_SHOWING, IDC_APPSTARTING, IDC_ARROW, IDC_CROSS,
IDC_HAND, IDC_HELP, IDC_IBEAM, IDC_NO, IDC_SIZEALL, IDC_SIZENESW, IDC_SIZENS, IDC_SIZENWSE,
IDC_SIZEWE, IDC_WAIT,
};
/// `(hcursor_or_0, global_x, global_y)`. `0` means the cursor is hidden.
pub fn cursor_state() -> Option<(u64, i32, i32)> {
unsafe {
let mut ci = CURSORINFO {
cbSize: std::mem::size_of::<CURSORINFO>() as u32,
..Default::default()
};
if GetCursorInfo(&mut ci).is_err() {
return None;
}
let showing = (ci.flags.0 & CURSOR_SHOWING.0) != 0 && !ci.hCursor.is_invalid();
let h = if showing { ci.hCursor.0 as u64 } else { 0 };
Some((h, ci.ptScreenPos.x, ci.ptScreenPos.y))
}
}
/// Map a cursor handle to a CSS cursor name by comparing it against the loaded
/// standard system cursors. Returns `None` for app-custom cursors.
pub fn cursor_css(hcursor: u64) -> Option<&'static str> {
if hcursor == 0 {
return None;
}
const STD: &[(windows::core::PCWSTR, &str)] = &[
(IDC_ARROW, "default"),
(IDC_IBEAM, "text"),
(IDC_HAND, "pointer"),
(IDC_CROSS, "crosshair"),
(IDC_WAIT, "wait"),
(IDC_APPSTARTING, "progress"),
(IDC_SIZEALL, "move"),
(IDC_SIZENS, "ns-resize"),
(IDC_SIZEWE, "ew-resize"),
(IDC_SIZENWSE, "nwse-resize"),
(IDC_SIZENESW, "nesw-resize"),
(IDC_NO, "not-allowed"),
(IDC_HELP, "help"),
];
unsafe {
for (idc, css) in STD {
if let Ok(h) = LoadCursorW(None, *idc) {
if h.0 as u64 == hcursor {
return Some(css);
}
}
}
}
None
}
/// A decoded custom-cursor bitmap ready for `cursor.shape` (PNG + hotspot), so
/// app-specific cursors render on the viewer instead of falling back to the
/// default pointer. RustDesk sends the raw cursor image the same way.
pub struct CursorBitmap {
pub png: Vec<u8>,
pub w: u32,
pub h: u32,
pub hx: i32,
pub hy: i32,
}
/// Extract a (color) cursor's RGBA bitmap + hotspot and PNG-encode it. Returns
/// None for monochrome cursors (rare for app-custom shapes; the standard mono
/// ones are already mapped to CSS) and on any GDI failure, so the caller simply
/// keeps the default pointer. Mirrors capture.rs's GetDIBits usage.
pub fn cursor_bitmap(hcursor: u64) -> Option<CursorBitmap> {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Graphics::Gdi::{
DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO,
BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HBITMAP, HGDIOBJ,
};
use windows::Win32::UI::WindowsAndMessaging::{GetIconInfo, HICON, ICONINFO};
if hcursor == 0 {
return None;
}
unsafe {
let mut ii = ICONINFO::default();
if GetIconInfo(HICON(hcursor as *mut std::ffi::c_void), &mut ii).is_err() {
return None;
}
// Always release the bitmaps GetIconInfo created for us.
let cleanup = |ii: &ICONINFO| {
if !ii.hbmColor.is_invalid() {
let _ = DeleteObject(HGDIOBJ(ii.hbmColor.0));
}
if !ii.hbmMask.is_invalid() {
let _ = DeleteObject(HGDIOBJ(ii.hbmMask.0));
}
};
if ii.hbmColor.is_invalid() {
// Monochrome cursor — fall back to the default pointer.
cleanup(&ii);
return None;
}
// Dimensions from the color bitmap.
let mut bm = BITMAP::default();
let got = GetObjectW(
HGDIOBJ(ii.hbmColor.0),
std::mem::size_of::<BITMAP>() as i32,
Some(&mut bm as *mut _ as *mut std::ffi::c_void),
);
if got == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 512 {
cleanup(&ii);
return None;
}
let (w, h) = (bm.bmWidth as u32, bm.bmHeight as u32);
let read_bgra = |hbm: HBITMAP| -> Option<Vec<u8>> {
let screen = GetDC(None);
let mut bi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: w as i32,
biHeight: -(h as i32), // top-down
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
..Default::default()
},
..Default::default()
};
let mut buf = vec![0u8; (w * h * 4) as usize];
let lines = GetDIBits(
screen,
hbm,
0,
h,
Some(buf.as_mut_ptr() as *mut std::ffi::c_void),
&mut bi,
DIB_RGB_COLORS,
);
ReleaseDC(None, screen);
if lines == 0 {
None
} else {
Some(buf)
}
};
let color = read_bgra(ii.hbmColor);
// The AND mask (same 32bpp read): nonzero means "transparent" where the
// color bitmap carries no alpha of its own.
let mask = read_bgra(ii.hbmMask);
let hotspot = (ii.xHotspot as i32, ii.yHotspot as i32);
cleanup(&ii);
let color = color?;
// BGRA -> RGBA. Use the color bitmap's own alpha if it has any; otherwise
// derive it from the AND mask (mask pixel != 0 => transparent).
let has_alpha = color.chunks_exact(4).any(|px| px[3] != 0);
let mut rgba = vec![0u8; (w * h * 4) as usize];
for (i, px) in color.chunks_exact(4).enumerate() {
let (b, g, r) = (px[0], px[1], px[2]);
let a = if has_alpha {
px[3]
} else if let Some(m) = &mask {
// AND mask: a "set" (white) pixel is transparent.
let mp = &m[i * 4..i * 4 + 4];
if mp[0] | mp[1] | mp[2] != 0 {
0
} else {
255
}
} else {
255
};
let o = i * 4;
rgba[o] = r;
rgba[o + 1] = g;
rgba[o + 2] = b;
rgba[o + 3] = a;
}
let _ = HANDLE::default(); // keep the HANDLE import used across windows versions
let png = encode_png(&rgba, w, h)?;
Some(CursorBitmap { png, w, h, hx: hotspot.0, hy: hotspot.1 })
}
}
/// Encode a top-down RGBA buffer to PNG bytes.
pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
let mut out = Vec::new();
{
let mut enc = png::Encoder::new(&mut out, w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header().ok()?;
writer.write_image_data(rgba).ok()?;
}
Some(out)
}
/// Decode PNG bytes to top-down RGBA8 + (w, h). Used for clipboard images that
/// arrive from a viewer, to hand arboard an `ImageData`.
pub(crate) fn decode_png(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
let dec = png::Decoder::new(std::io::Cursor::new(bytes));
let mut reader = dec.read_info().ok()?;
let mut buf = vec![0u8; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).ok()?;
buf.truncate(info.buffer_size());
// Normalize to RGBA8.
let rgba = match (info.color_type, info.bit_depth) {
(png::ColorType::Rgba, png::BitDepth::Eight) => buf,
(png::ColorType::Rgb, png::BitDepth::Eight) => {
let mut out = Vec::with_capacity((info.width * info.height * 4) as usize);
for px in buf.chunks_exact(3) {
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
out
}
_ => return None, // uncommon formats -> skip (clipboard images are RGB/RGBA8)
};
Some((rgba, info.width, info.height))
}
#[cfg(test)]
mod tests {
use super::*;
// Extract a real system cursor's bitmap (IDC_HAND is a color cursor on
// modern Windows). In production cursor_css() catches the standard ones
// first; here we call cursor_bitmap directly to prove the GDI extraction +
// PNG encode produce a valid, sensibly-sized image.
#[test]
fn extracts_a_real_cursor_to_png() {
let h = unsafe { LoadCursorW(None, IDC_HAND) };
let h = match h {
Ok(h) => h.0 as u64,
Err(_) => return, // no interactive cursor resource (headless CI) — skip
};
if let Some(bmp) = cursor_bitmap(h) {
assert!(bmp.w >= 8 && bmp.w <= 512, "width sane: {}", bmp.w);
assert!(bmp.h >= 8 && bmp.h <= 512, "height sane: {}", bmp.h);
// Valid PNG signature.
assert_eq!(&bmp.png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
// Round-trips through a decoder back to w*h*4 RGBA.
let dec = png::Decoder::new(std::io::Cursor::new(&bmp.png));
let mut reader = dec.read_info().expect("png header");
let mut buf = vec![0u8; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).expect("png frame");
assert_eq!(info.width, bmp.w);
assert_eq!(info.height, bmp.h);
}
// If extraction returned None (e.g. a monochrome IDC_HAND on some
// builds), that's the documented safe fallback — not a failure.
}
}
#[cfg(test)]
mod clip_png_tests {
use super::{decode_png, encode_png};
#[test]
fn png_rgba_roundtrip_for_clipboard_images() {
// A 2x2 RGBA image round-trips encode -> decode byte-for-byte.
let w = 2u32;
let h = 2u32;
let rgba: Vec<u8> = vec![
255, 0, 0, 255, 0, 255, 0, 255,
0, 0, 255, 128, 10, 20, 30, 40,
];
let png = encode_png(&rgba, w, h).expect("encode");
assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
let (got, gw, gh) = decode_png(&png).expect("decode");
assert_eq!((gw, gh), (w, h));
assert_eq!(got, rgba, "clipboard image pixels survive the round-trip");
}
}
+74
View File
@@ -0,0 +1,74 @@
//! H.264 encode via openh264 (pure-Rust-friendly, statically linked C core) —
//! the portable software path that works on every Windows PC with no driver or
//! GPU dependency. BGRA (from DXGI capture) is converted to I420 and encoded as
//! Annex-B access units: keyframes carry SPS+PPS+IDR, deltas carry a P slice,
//! exactly what the viewer's openh264/Media Foundation decoder expects.
//!
//! Hardware encoders (NVENC / QSV / AMF) layer in later behind this same
//! `EncodedFrame` output; software-first guarantees the host always works.
use anyhow::{anyhow, Result};
use openh264::encoder::{Encoder, EncoderConfig, FrameType, RateControlMode, UsageType};
use openh264::formats::{BgraSliceU8, YUVBuffer};
use openh264::OpenH264API;
/// One encoded access unit ready to hand to `core::host`, which prefixes the
/// video channel + keyframe byte and encrypts it.
pub struct EncodedFrame {
pub data: Vec<u8>,
pub keyframe: bool,
}
/// A live H.264 encoder for one resolution. Resolution changes re-init the
/// encoder automatically (openh264 does this on dimension change).
pub struct H264Encoder {
enc: Encoder,
width: usize,
height: usize,
}
impl H264Encoder {
pub fn new(width: usize, height: usize, fps: u32, bitrate_bps: u32) -> Result<Self> {
let config = EncoderConfig::new()
.set_bitrate_bps(bitrate_bps)
.max_frame_rate(fps as f32)
.rate_control_mode(RateControlMode::Bitrate)
.usage_type(UsageType::ScreenContentRealTime)
.enable_skip_frame(true)
.set_multiple_thread_idc(0); // auto: use all cores
let enc = Encoder::with_api_config(OpenH264API::from_source(), config)
.map_err(|e| anyhow!("openh264 encoder init: {e}"))?;
Ok(Self { enc, width, height })
}
/// Encode one tightly-packed BGRA frame (`width*height*4`). Returns `None`
/// when rate control skipped this frame (nothing to send this tick).
pub fn encode(&mut self, bgra: &[u8]) -> Result<Option<EncodedFrame>> {
let src = BgraSliceU8::new(bgra, (self.width, self.height));
let yuv = YUVBuffer::from_rgb_source(src);
let bs = self.enc.encode(&yuv).map_err(|e| anyhow!("encode: {e}"))?;
let ft = bs.frame_type();
if ft == FrameType::Skip || ft == FrameType::Invalid {
return Ok(None);
}
let data = bs.to_vec();
if data.is_empty() {
return Ok(None);
}
let keyframe = matches!(ft, FrameType::IDR | FrameType::I);
Ok(Some(EncodedFrame { data, keyframe }))
}
/// Force the next encoded frame to be a keyframe (viewer asked for a refresh
/// after a drop, or a new viewer joined).
pub fn request_keyframe(&mut self) {
self.enc.force_intra_frame();
}
pub fn width(&self) -> usize {
self.width
}
pub fn height(&self) -> usize {
self.height
}
}
+486
View File
@@ -0,0 +1,486 @@
//! Windows hardware H.264 encode via Media Foundation.
//!
//! Enumerates the PC's hardware H.264 encoder MFTs (NVIDIA NVENC, Intel Quick
//! Sync, AMD VCE/AMF — whatever is present) and drives the async MFT with a
//! deadlock-free event pump. BGRA from DXGI capture is converted to NV12 and
//! fed in; Annex-B access units come out, with SPS/PPS guaranteed on keyframes.
//!
//! This is the "use any hardware encoder and any GPU accelerator on any Windows
//! PC" path. If no hardware encoder is present or init fails, the caller falls
//! back to the portable openh264 software encoder.
#![cfg(windows)]
use crate::color::bgra_to_nv12;
use crate::encode::EncodedFrame;
use anyhow::{anyhow, Result};
use std::ffi::c_void;
use windows::core::{Interface, VARIANT, GUID, PWSTR};
use windows::Win32::Media::MediaFoundation::*;
use windows::Win32::System::Com::{CoInitializeEx, CoTaskMemFree, COINIT_MULTITHREADED};
/// MF_MT_FRAME_SIZE / FRAME_RATE / PIXEL_ASPECT_RATIO are packed UINT64s
/// ((hi << 32) | lo). windows-rs does not expose the inline MFSetAttribute*
/// helpers, so we pack them ourselves.
unsafe fn set_size(mt: &IMFMediaType, key: &GUID, w: u32, h: u32) -> Result<()> {
mt.SetUINT64(key, ((w as u64) << 32) | (h as u64))?;
Ok(())
}
unsafe fn set_ratio(mt: &IMFMediaType, key: &GUID, num: u32, den: u32) -> Result<()> {
mt.SetUINT64(key, ((num as u64) << 32) | (den as u64))?;
Ok(())
}
/// ~60 fps in 100-ns units; the stream is low-latency so stamps only need to be
/// monotonic.
const HNS_PER_FRAME: i64 = 166_667;
pub struct MfEncoder {
transform: IMFTransform,
events: IMFMediaEventGenerator,
width: usize,
height: usize,
nv12: Vec<u8>,
seq_header: Vec<u8>,
provides_samples: bool,
output_size: usize,
need_credits: u32,
pts: i64,
hardware: bool,
name: String,
}
impl MfEncoder {
pub fn new(width: usize, height: usize, fps: u32, bitrate_bps: u32) -> Result<Self> {
unsafe {
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
MFStartup(MF_VERSION, MFSTARTUP_FULL).map_err(|e| anyhow!("MFStartup: {e}"))?;
// Enumerate hardware H.264 encoders: NV12 in, H264 out.
let input_info = MFT_REGISTER_TYPE_INFO {
guidMajorType: MFMediaType_Video,
guidSubtype: MFVideoFormat_NV12,
};
let output_info = MFT_REGISTER_TYPE_INFO {
guidMajorType: MFMediaType_Video,
guidSubtype: MFVideoFormat_H264,
};
let flags =
MFT_ENUM_FLAG_HARDWARE | MFT_ENUM_FLAG_ASYNCMFT | MFT_ENUM_FLAG_SORTANDFILTER;
let mut pp: *mut Option<IMFActivate> = std::ptr::null_mut();
let mut count: u32 = 0;
MFTEnumEx(
MFT_CATEGORY_VIDEO_ENCODER,
flags,
Some(&input_info),
Some(&output_info),
&mut pp,
&mut count,
)
.map_err(|e| anyhow!("MFTEnumEx: {e}"))?;
if count == 0 || pp.is_null() {
return Err(anyhow!("no hardware H.264 encoder MFT found"));
}
let activations = std::slice::from_raw_parts(pp, count as usize);
let mut chosen: Option<(IMFTransform, String)> = None;
for act in activations.iter().flatten() {
let mut pw = PWSTR::null();
let mut len = 0u32;
let name = if act
.GetAllocatedString(&MFT_FRIENDLY_NAME_Attribute, &mut pw, &mut len)
.is_ok()
&& !pw.is_null()
{
let s =
String::from_utf16_lossy(std::slice::from_raw_parts(pw.0, len as usize));
CoTaskMemFree(Some(pw.0 as *const c_void));
s
} else {
String::new()
};
if let Ok(t) = act.ActivateObject::<IMFTransform>() {
chosen = Some((t, name));
break;
}
}
CoTaskMemFree(Some(pp as *const c_void));
let (transform, name) =
chosen.ok_or_else(|| anyhow!("failed to activate any encoder MFT"))?;
// Async MFT: unlock before use, request low latency (1-in-1-out).
if let Ok(attrs) = transform.GetAttributes() {
let _ = attrs.SetUINT32(&MF_TRANSFORM_ASYNC_UNLOCK, 1);
let _ = attrs.SetUINT32(&MF_LOW_LATENCY, 1);
}
// OUTPUT type first (required for encoders): H.264.
let out_t = MFCreateMediaType().map_err(|e| anyhow!("MFCreateMediaType(out): {e}"))?;
out_t.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
out_t.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?;
out_t.SetUINT32(&MF_MT_AVG_BITRATE, bitrate_bps)?;
out_t.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?;
out_t.SetUINT32(&MF_MT_MPEG2_PROFILE, eAVEncH264VProfile_Main.0 as u32)?;
set_size(&out_t, &MF_MT_FRAME_SIZE, width as u32, height as u32)?;
set_ratio(&out_t, &MF_MT_FRAME_RATE, fps, 1)?;
set_ratio(&out_t, &MF_MT_PIXEL_ASPECT_RATIO, 1, 1)?;
transform
.SetOutputType(0, &out_t, 0)
.map_err(|e| anyhow!("SetOutputType(H264): {e}"))?;
// INPUT type: NV12.
let in_t = MFCreateMediaType().map_err(|e| anyhow!("MFCreateMediaType(in): {e}"))?;
in_t.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
in_t.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?;
in_t.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?;
set_size(&in_t, &MF_MT_FRAME_SIZE, width as u32, height as u32)?;
set_ratio(&in_t, &MF_MT_FRAME_RATE, fps, 1)?;
set_ratio(&in_t, &MF_MT_PIXEL_ASPECT_RATIO, 1, 1)?;
transform
.SetInputType(0, &in_t, 0)
.map_err(|e| anyhow!("SetInputType(NV12): {e}"))?;
// Prefer ON-DEMAND keyframes (viewer join / refresh / QoS step) over a
// tight periodic GOP: set a long GOP as a safety net and drive IDRs via
// ICodecAPI force-keyframe (see request_keyframe), so the bitstream stays
// lean on a static screen and a join gets a crisp keyframe without the
// old whole-encoder re-init hitch. Best-effort (ignored if unsupported).
if let Ok(api) = transform.cast::<ICodecAPI>() {
let _ = api.SetValue(&CODECAPI_AVEncMPVGOPSize, &VARIANT::from(120u32));
}
// SPS/PPS sequence header, to guarantee on keyframes.
let mut seq_header = Vec::new();
if let Ok(cur) = transform.GetOutputCurrentType(0) {
if let Ok(sz) = cur.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) {
if sz > 0 {
let mut buf = vec![0u8; sz as usize];
if cur
.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None)
.is_ok()
{
seq_header = buf;
}
}
}
}
let info = transform
.GetOutputStreamInfo(0)
.map_err(|e| anyhow!("GetOutputStreamInfo: {e}"))?;
let provides = (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0
| MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES.0) as u32;
let provides_samples = (info.dwFlags & provides) != 0;
let output_size = info.cbSize as usize;
let events: IMFMediaEventGenerator = transform
.cast()
.map_err(|e| anyhow!("MFT is not an event generator (not async?): {e}"))?;
transform.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0)?;
transform.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0)?;
Ok(Self {
transform,
events,
width,
height,
nv12: vec![0u8; width * height * 3 / 2],
seq_header,
provides_samples,
output_size,
need_credits: 0,
pts: 0,
hardware: true,
name,
})
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn is_hardware(&self) -> bool {
self.hardware
}
pub fn width(&self) -> usize {
self.width
}
pub fn height(&self) -> usize {
self.height
}
/// Encode one BGRA frame. Returns 0+ encoded AUs (an async encoder may run a
/// frame or two of latency; outputs arrive in order).
pub fn encode(&mut self, bgra: &[u8]) -> Result<Vec<EncodedFrame>> {
unsafe {
bgra_to_nv12(bgra, self.width, self.height, &mut self.nv12);
let sample = self.make_input_sample()?;
let mut out = Vec::new();
// Wait (blocking) until the MFT has asked for input at least once.
while self.need_credits == 0 {
let ev = self
.events
.GetEvent(MF_EVENT_FLAG_NONE)
.map_err(|e| anyhow!("GetEvent(block): {e}"))?;
self.handle_event(&ev, &mut out)?;
}
self.transform
.ProcessInput(0, &sample, 0)
.map_err(|e| anyhow!("ProcessInput: {e}"))?;
self.need_credits -= 1;
// Collect whatever output is ready right now, without blocking.
self.drain_ready(&mut out)?;
Ok(out)
}
}
/// Flush the pipeline tail (used by the bench / on shutdown).
pub fn drain(&mut self) -> Result<Vec<EncodedFrame>> {
unsafe {
let mut out = Vec::new();
self.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0)?;
self.transform.ProcessMessage(MFT_MESSAGE_COMMAND_DRAIN, 0)?;
loop {
let ev = self
.events
.GetEvent(MF_EVENT_FLAG_NONE)
.map_err(|e| anyhow!("GetEvent(drain): {e}"))?;
let t = ev.GetType()?;
if t == METransformDrainComplete.0 as u32 {
break;
}
self.handle_event(&ev, &mut out)?;
}
Ok(out)
}
}
unsafe fn drain_ready(&mut self, out: &mut Vec<EncodedFrame>) -> Result<()> {
loop {
match self.events.GetEvent(MF_EVENT_FLAG_NO_WAIT) {
Ok(ev) => self.handle_event(&ev, out)?,
Err(e) if e.code() == MF_E_NO_EVENTS_AVAILABLE => return Ok(()),
Err(e) => return Err(anyhow!("GetEvent(nowait): {e}")),
}
}
}
unsafe fn handle_event(
&mut self,
ev: &IMFMediaEvent,
out: &mut Vec<EncodedFrame>,
) -> Result<()> {
let t = ev.GetType()?;
if t == METransformNeedInput.0 as u32 {
self.need_credits += 1;
} else if t == METransformHaveOutput.0 as u32 {
if let Some(f) = self.pull_output()? {
out.push(f);
}
}
Ok(())
}
unsafe fn make_input_sample(&mut self) -> Result<IMFSample> {
let sample = MFCreateSample()?;
let buffer = MFCreateMemoryBuffer(self.nv12.len() as u32)?;
let mut ptr: *mut u8 = std::ptr::null_mut();
buffer.Lock(&mut ptr, None, None)?;
std::ptr::copy_nonoverlapping(self.nv12.as_ptr(), ptr, self.nv12.len());
buffer.Unlock()?;
buffer.SetCurrentLength(self.nv12.len() as u32)?;
sample.AddBuffer(&buffer)?;
let _ = sample.SetSampleTime(self.pts);
let _ = sample.SetSampleDuration(HNS_PER_FRAME);
self.pts += HNS_PER_FRAME;
Ok(sample)
}
/// Pick the H.264 output type (re)offered by the MFT and refresh the stored
/// SPS/PPS. Hardware encoders finalize their output type after the first
/// input and signal MF_E_TRANSFORM_STREAM_CHANGE; this renegotiates it.
unsafe fn renegotiate_output(&mut self) -> Result<()> {
let t = self
.transform
.GetOutputAvailableType(0, 0)
.map_err(|e| anyhow!("GetOutputAvailableType(renegotiate): {e}"))?;
self.transform
.SetOutputType(0, &t, 0)
.map_err(|e| anyhow!("SetOutputType(renegotiate): {e}"))?;
if let Ok(sz) = t.GetBlobSize(&MF_MT_MPEG_SEQUENCE_HEADER) {
if sz > 0 {
let mut buf = vec![0u8; sz as usize];
if t.GetBlob(&MF_MT_MPEG_SEQUENCE_HEADER, &mut buf, None).is_ok() {
self.seq_header = buf;
}
}
}
if let Ok(info) = self.transform.GetOutputStreamInfo(0) {
let provides = (MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0
| MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES.0) as u32;
self.provides_samples = (info.dwFlags & provides) != 0;
self.output_size = info.cbSize as usize;
}
Ok(())
}
unsafe fn pull_output(&mut self) -> Result<Option<EncodedFrame>> {
let sample_slot: Option<IMFSample> = if self.provides_samples {
None
} else {
let s = MFCreateSample()?;
let b = MFCreateMemoryBuffer(self.output_size.max(self.width * self.height) as u32)?;
s.AddBuffer(&b)?;
Some(s)
};
let mut buffers = [MFT_OUTPUT_DATA_BUFFER {
dwStreamID: 0,
pSample: std::mem::ManuallyDrop::new(sample_slot),
dwStatus: 0,
pEvents: std::mem::ManuallyDrop::new(None),
}];
let mut status = 0u32;
let res = self.transform.ProcessOutput(0, &mut buffers, &mut status);
let out_sample = std::mem::ManuallyDrop::take(&mut buffers[0].pSample);
let _events = std::mem::ManuallyDrop::take(&mut buffers[0].pEvents);
match res {
Ok(()) => {
let s = out_sample.ok_or_else(|| anyhow!("ProcessOutput gave no sample"))?;
let keyframe = s.GetUINT32(&MFSampleExtension_CleanPoint).unwrap_or(0) == 1;
let buf = s.GetBufferByIndex(0)?;
let mut ptr: *mut u8 = std::ptr::null_mut();
let mut len = 0u32;
buf.Lock(&mut ptr, None, Some(&mut len))?;
let mut data = std::slice::from_raw_parts(ptr, len as usize).to_vec();
buf.Unlock()?;
if keyframe && !self.seq_header.is_empty() && !starts_with_sps(&data) {
let mut with_hdr = self.seq_header.clone();
with_hdr.extend_from_slice(&data);
data = with_hdr;
}
Ok(Some(EncodedFrame { data, keyframe }))
}
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => Ok(None),
Err(e) if e.code() == MF_E_TRANSFORM_STREAM_CHANGE => {
// Encoder finalized its output format. Renegotiate; the pending
// output arrives on the next METransformHaveOutput event (QSV
// catastrophes on a synchronous retry here).
self.renegotiate_output()?;
Ok(None)
}
Err(e) => Err(anyhow!("ProcessOutput: {e}")),
}
}
/// Force the next encoded frame to be a keyframe (IDR) via ICodecAPI
/// CODECAPI_AVEncVideoForceKeyFrame — NVENC/QSV/AMF all honor it — instead of
/// tearing down + rebuilding the whole MFT (the old approach, which stalled
/// the stream on every viewer join / refresh / QoS bitrate step).
pub fn request_keyframe(&mut self) {
unsafe {
if let Ok(api) = self.transform.cast::<ICodecAPI>() {
let _ = api.SetValue(&CODECAPI_AVEncVideoForceKeyFrame, &VARIANT::from(1u32));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Prove the ICodecAPI force-keyframe path emits an IDR ON DEMAND without a
/// rebuild. With the long (120) GOP set at init, the only natural keyframe in
/// a short run is the initial IDR at frame 0; a keyframe appearing shortly
/// after request_keyframe() (and well before frame 120) is therefore the
/// forced one. Skips cleanly if the box has no hardware H.264 MFT.
#[test]
fn forced_keyframe_emits_idr_on_demand() {
let (w, h) = (320usize, 240usize);
let mut enc = match MfEncoder::new(w, h, 30, 1_000_000) {
Ok(e) => e,
Err(_) => return, // no hardware encoder on this box — skip
};
let frame = vec![0x70u8; w * h * 4]; // solid BGRA
let mut kf_idx = Vec::new();
let mut idx = 0usize;
// Prime ~40 frames: collect keyframe positions. With GOP=120 only frame 0
// should be a keyframe.
for _ in 0..40 {
if let Ok(frames) = enc.encode(&frame) {
for f in frames {
if f.keyframe {
kf_idx.push(idx);
}
idx += 1;
}
}
}
let before = idx;
enc.request_keyframe();
for _ in 0..12 {
if let Ok(frames) = enc.encode(&frame) {
for f in frames {
if f.keyframe {
kf_idx.push(idx);
}
idx += 1;
}
}
}
if let Ok(frames) = enc.drain() {
for f in frames {
if f.keyframe {
kf_idx.push(idx);
}
idx += 1;
}
}
// If the encoder emitted essentially no frames (some CI GPUs stall under
// a headless session), don't assert on timing — just ensure no panic.
if before < 5 {
return;
}
assert!(
kf_idx.iter().any(|&p| p >= before),
"forced keyframe should emit an IDR after request_keyframe(); \
keyframes at {kf_idx:?}, forced after frame {before} (GOP=120)"
);
}
}
/// Does this Annex-B buffer begin (after a start code) with an SPS NAL (type 7)?
fn starts_with_sps(d: &[u8]) -> bool {
let mut i = 0;
if d.len() >= 4 && d[0] == 0 && d[1] == 0 && d[2] == 0 && d[3] == 1 {
i = 4;
} else if d.len() >= 3 && d[0] == 0 && d[1] == 0 && d[2] == 1 {
i = 3;
}
d.get(i).map(|b| (b & 0x1f) == 7).unwrap_or(false)
}
impl Drop for MfEncoder {
fn drop(&mut self) {
unsafe {
let _ = self
.transform
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);
}
}
}
// The MFT is only ever touched from the one thread that owns this encoder; MF
// objects are agile under MTA.
unsafe impl Send for MfEncoder {}
+218
View File
@@ -0,0 +1,218 @@
//! Unified video encoder: prefer the platform hardware encoder (Media
//! Foundation NVENC/QSV/AMF on Windows), fall back to the portable openh264
//! software encoder, or — on constrained links — VP9 software (libvpx), which
//! is far better per-bitrate than H.264. One `encode` call returns 0+ coded
//! frames (the async hardware path can run a frame of latency; software is 1:1).
use crate::encode::{EncodedFrame, H264Encoder};
use anyhow::Result;
pub enum VideoEncoder {
#[cfg(windows)]
Hardware(crate::encode_mf::MfEncoder),
Software(H264Encoder),
#[cfg(windows)]
Vp9(Vp9State),
}
/// VP9 encoder state. libvpx is stateless about wall-clock, so we track a start
/// instant (ms PTS) and a pending-keyframe flag (the encode loop asks for a
/// keyframe on viewer join / refresh / re-init).
#[cfg(windows)]
pub struct Vp9State {
enc: netdesk_vpx::Encoder,
want_key: bool,
start: std::time::Instant,
}
impl VideoEncoder {
/// Build the best available H.264 encoder for this machine (hardware MF,
/// else openh264 software). This is the default fast-link path.
pub fn best(width: usize, height: usize, fps: u32, bitrate_bps: u32) -> Result<Self> {
#[cfg(windows)]
{
match crate::encode_mf::MfEncoder::new(width, height, fps, bitrate_bps) {
Ok(e) => {
log::info!("encode: hardware \"{}\"", e.name());
return Ok(VideoEncoder::Hardware(e));
}
Err(e) => {
log::warn!("encode: no hardware encoder ({e}); using openh264 software");
}
}
}
let e = H264Encoder::new(width, height, fps, bitrate_bps)?;
log::info!("encode: openh264 software");
Ok(VideoEncoder::Software(e))
}
/// Build an encoder for a specific codec (`proto::VideoCodec`): VP9 (1) uses
/// libvpx software; anything else falls back to [`best`] (H.264). VP9 needs
/// even dimensions, so callers must pass even `width`/`height` (the capture
/// loop uses `color::scaled_even`).
pub fn with_codec(
codec_id: u8,
i444: bool,
width: usize,
height: usize,
fps: u32,
bitrate_bps: u32,
) -> Result<Self> {
#[cfg(windows)]
{
if codec_id == 1 {
let kbps = (bitrate_bps / 1000).max(50);
match netdesk_vpx::Encoder::new_i444(
netdesk_vpx::VpxCodec::Vp9,
width as u32,
height as u32,
fps,
kbps,
i444,
) {
Ok(enc) => {
log::info!(
"encode: VP9 software (libvpx) {width}x{height} @ {kbps} kbps{}",
if i444 { " 4:4:4" } else { "" }
);
return Ok(VideoEncoder::Vp9(Vp9State {
enc,
want_key: true, // first frame is a keyframe anyway
start: std::time::Instant::now(),
}));
}
Err(e) => {
log::warn!("encode: VP9 init failed ({e}); using H.264");
}
}
}
}
let _ = codec_id;
Self::best(width, height, fps, bitrate_bps)
}
pub fn encode(&mut self, bgra: &[u8]) -> Result<Vec<EncodedFrame>> {
match self {
#[cfg(windows)]
VideoEncoder::Hardware(e) => e.encode(bgra),
VideoEncoder::Software(e) => Ok(e.encode(bgra)?.into_iter().collect()),
#[cfg(windows)]
VideoEncoder::Vp9(s) => {
let pts = s.start.elapsed().as_millis() as i64;
let force_key = std::mem::take(&mut s.want_key);
let frames = s.enc.encode_bgra(bgra, pts, force_key)?;
Ok(frames
.into_iter()
.map(|f| EncodedFrame {
data: f.data,
keyframe: f.key,
})
.collect())
}
}
}
pub fn request_keyframe(&mut self) {
match self {
#[cfg(windows)]
VideoEncoder::Hardware(e) => e.request_keyframe(),
VideoEncoder::Software(e) => e.request_keyframe(),
#[cfg(windows)]
VideoEncoder::Vp9(s) => s.want_key = true,
}
}
/// Codec id for the frames this encoder emits (`proto::VideoCodec`):
/// 0 = H.264 (hardware MF / openh264 software), 1 = VP9.
pub fn codec_id(&self) -> u8 {
match self {
#[cfg(windows)]
VideoEncoder::Hardware(_) => 0,
VideoEncoder::Software(_) => 0,
#[cfg(windows)]
VideoEncoder::Vp9(_) => 1,
}
}
pub fn is_hardware(&self) -> bool {
match self {
#[cfg(windows)]
VideoEncoder::Hardware(_) => true,
VideoEncoder::Software(_) => false,
#[cfg(windows)]
VideoEncoder::Vp9(_) => false,
}
}
pub fn label(&self) -> String {
match self {
#[cfg(windows)]
VideoEncoder::Hardware(e) => format!("hardware: {}", e.name()),
VideoEncoder::Software(_) => "software: openh264".to_string(),
#[cfg(windows)]
VideoEncoder::Vp9(_) => "software: VP9 (libvpx)".to_string(),
}
}
}
#[cfg(all(test, windows))]
mod tests {
use super::*;
use netdesk_core::decode::best_decoder_for;
// End-to-end through the real host encoder wrapper + the core decoder
// selector: BGRA -> VideoEncoder(VP9) -> best_decoder_for(1) -> RGB, proving
// the codec_id tag, pts/keyframe handling, and the BGRA<->RGB conversions
// all line up (not just the raw netdesk-vpx roundtrip).
#[test]
fn vp9_encode_decode_via_video_encoder() {
let (w, h) = (128usize, 96usize);
let mut bgra = vec![0u8; w * h * 4];
for j in 0..h {
for i in 0..w {
let p = (j * w + i) * 4;
let (b, g, r) = if j < h / 2 && i < w / 2 {
(30u8, 30, 200) // red
} else if j < h / 2 {
(30, 200, 30) // green
} else if i < w / 2 {
(200, 30, 30) // blue
} else {
(200, 200, 200) // gray
};
bgra[p] = b;
bgra[p + 1] = g;
bgra[p + 2] = r;
bgra[p + 3] = 255;
}
}
let mut enc = VideoEncoder::with_codec(1, false, w, h, 30, 2_000_000).expect("vp9 encoder");
assert_eq!(enc.codec_id(), 1, "must be VP9");
let frames = enc.encode(&bgra).expect("encode");
assert!(!frames.is_empty(), "no VP9 packets");
assert!(frames[0].keyframe, "first VP9 frame must be a keyframe");
let mut dec = best_decoder_for(1);
let mut got = None;
for f in &frames {
if let Ok(Some(fr)) = dec.decode(&f.data) {
got = Some(fr);
}
}
let fr = got.expect("decoded a frame");
assert_eq!((fr.w, fr.h), (w, h), "decoded dims");
let sample = |x: usize, y: usize| {
let p = (y * w + x) * 3;
(fr.rgb[p], fr.rgb[p + 1], fr.rgb[p + 2]) // (R, G, B)
};
let approx = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 48;
let (r, g, b) = sample(w / 4, h / 4); // red quadrant
assert!(
approx(r, 200) && approx(g, 30) && approx(b, 30),
"red quad decoded as r{r} g{g} b{b}"
);
println!("VP9_E2E_OK {w}x{h} key_bytes={}", frames[0].data.len());
}
}
+188
View File
@@ -0,0 +1,188 @@
//! Host-side file manager: enumerate drives/roots and directory contents, and
//! the chunked read/write used by download/upload. Items are reported in the
//! viewer's wire shape `{n:name, t:"d"|"f", s:size, m:mtime}`.
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
/// Top-level roots: every present drive on Windows (`C:\`, `D:\`, …), plus the
/// user's home, so the viewer's file panel has somewhere to start.
pub fn roots() -> Vec<Value> {
let mut items = Vec::new();
#[cfg(windows)]
{
for c in b'A'..=b'Z' {
let p = format!("{}:\\", c as char);
if Path::new(&p).exists() {
items.push(json!({ "n": p, "t": "d", "s": 0, "m": 0 }));
}
}
}
#[cfg(not(windows))]
{
items.push(json!({ "n": "/", "t": "d", "s": 0, "m": 0 }));
}
items
}
/// List a directory. Entries are sorted folders-first then by name; hidden
/// handling is left to the OS (we surface everything `read_dir` returns).
pub fn list_dir(path: &str) -> std::io::Result<Vec<Value>> {
let mut items = Vec::new();
for entry in std::fs::read_dir(path)? {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let name = entry.file_name().to_string_lossy().to_string();
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let is_dir = meta.is_dir();
let size = if is_dir { 0 } else { meta.len() };
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
items.push(json!({
"n": name,
"t": if is_dir { "d" } else { "f" },
"s": size,
"m": mtime,
}));
}
items.sort_by(|a, b| {
let ad = a.get("t").and_then(|x| x.as_str()) == Some("d");
let bd = b.get("t").and_then(|x| x.as_str()) == Some("d");
bd.cmp(&ad).then_with(|| {
a.get("n")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_ascii_lowercase()
.cmp(&b.get("n").and_then(|x| x.as_str()).unwrap_or("").to_ascii_lowercase())
})
});
Ok(items)
}
/// Recursively walk `root` and return every FILE under it as
/// `{ "rel": "<forward-slash relative path>", "s": <size> }`, so a viewer can
/// recreate the tree locally and download each entry. Directories are implied
/// by the relative paths (the viewer mkdir -p's each parent). Bounded to avoid
/// a runaway walk: at most `MAX_ENTRIES` files and `MAX_DEPTH` deep; symlinks
/// are not followed. Returns (entries, truncated).
pub fn walk_dir(root: &str) -> std::io::Result<(Vec<Value>, bool)> {
const MAX_ENTRIES: usize = 50_000;
const MAX_DEPTH: usize = 40;
let root_path = PathBuf::from(root);
let mut out = Vec::new();
let mut truncated = false;
// Iterative DFS: (dir, depth). rel paths are built from `root`.
let mut stack = vec![(root_path.clone(), 0usize)];
while let Some((dir, depth)) = stack.pop() {
if depth > MAX_DEPTH {
truncated = true;
continue;
}
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue, // unreadable dir -> skip, keep going
};
for entry in rd {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
// symlink_metadata: do NOT follow symlinks (avoid cycles / escapes).
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let path = entry.path();
if meta.file_type().is_symlink() {
continue;
}
if meta.is_dir() {
stack.push((path, depth + 1));
} else if meta.is_file() {
if out.len() >= MAX_ENTRIES {
truncated = true;
break;
}
// Relative path with forward slashes (the viewer joins it under
// the chosen local folder).
let rel = path
.strip_prefix(&root_path)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
out.push(json!({ "rel": rel, "s": meta.len() }));
}
}
if out.len() >= MAX_ENTRIES {
truncated = true;
break;
}
}
out.sort_by(|a, b| {
a.get("rel").and_then(|x| x.as_str()).unwrap_or("")
.cmp(b.get("rel").and_then(|x| x.as_str()).unwrap_or(""))
});
Ok((out, truncated))
}
/// Where an in-progress upload is written before the atomic rename into place.
pub fn part_path(final_path: &str) -> PathBuf {
PathBuf::from(format!("{final_path}.netdesk.part"))
}
/// Resolve an upload destination: a relative/empty dir drops onto the Desktop
/// (TeamViewer-style default), mirroring the existing host.
pub fn resolve_upload_path(path: &str) -> PathBuf {
let p = Path::new(path);
if p.is_absolute() {
return p.to_path_buf();
}
// Relative: drop on the Desktop.
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".into());
Path::new(&home).join("Desktop").join(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn walk_dir_recurses_and_relativizes() {
// Build a small temp tree: root/{a.txt, sub/b.txt, sub/deep/c.txt}.
let base = std::env::temp_dir().join(format!("nd_walk_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("sub").join("deep")).unwrap();
std::fs::write(base.join("a.txt"), b"AA").unwrap();
std::fs::write(base.join("sub").join("b.txt"), b"BBBB").unwrap();
std::fs::write(base.join("sub").join("deep").join("c.txt"), b"CCCCCC").unwrap();
let (entries, truncated) = walk_dir(&base.to_string_lossy()).unwrap();
assert!(!truncated);
let rels: Vec<String> = entries
.iter()
.map(|e| e.get("rel").and_then(|x| x.as_str()).unwrap().to_string())
.collect();
assert_eq!(rels, vec!["a.txt", "sub/b.txt", "sub/deep/c.txt"]); // sorted, forward slashes
// sizes carried through
let size = |rel: &str| {
entries.iter().find(|e| e.get("rel").and_then(|x| x.as_str()) == Some(rel))
.and_then(|e| e.get("s").and_then(|x| x.as_u64())).unwrap()
};
assert_eq!(size("a.txt"), 2);
assert_eq!(size("sub/b.txt"), 4);
assert_eq!(size("sub/deep/c.txt"), 6);
let _ = std::fs::remove_dir_all(&base);
}
}
+521
View File
@@ -0,0 +1,521 @@
//! Input injection via SendInput — mouse move/buttons/wheel and keyboard, wire
//! compatible with the existing NetDesk viewers (same convention the Python host
//! uses): pointer coordinates arrive in virtual-desktop pixel space and are
//! mapped to the 0..65535 absolute range over the whole virtual desktop; keys
//! arrive as DOM-style codes ("KeyA", "Enter", "ArrowLeft") and are injected as
//! scan codes so games and low-level apps see them correctly.
#![cfg(windows)]
use windows::Win32::UI::Input::KeyboardAndMouse::{
MapVirtualKeyW, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT,
KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE,
KEYEVENTF_UNICODE, MAPVK_VK_TO_VSC,
MOUSEINPUT, MOUSE_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN,
MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE,
MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL,
MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, VIRTUAL_KEY,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
};
/// Stateful input injector. Tracks the current mouse-button bitmask so it can
/// emit the correct press/release transitions.
pub struct Injector {
buttons: u32,
}
impl Default for Injector {
fn default() -> Self {
Self::new()
}
}
impl Injector {
pub fn new() -> Self {
Self { buttons: 0 }
}
/// Inject one pointer update: move, then any button transitions, then wheel.
pub fn mouse(&mut self, x: i32, y: i32, buttons: u32, wheel: f32) {
self.mouse_move(x, y);
if buttons != self.buttons {
self.mouse_buttons(buttons, self.buttons);
self.buttons = buttons;
}
if wheel != 0.0 {
self.wheel(wheel);
}
}
/// Inject a RELATIVE pointer update (game / pointer-lock mode): move by
/// (dx, dy) pixels using MOUSEEVENTF_MOVE WITHOUT ABSOLUTE, then any
/// button transitions, then wheel. Apps that read raw mouse motion (FPS
/// games, 3D/CAD) receive the deltas directly. Subject to the system
/// pointer-speed/accel settings, like any relative motion.
pub fn mouse_rel(&mut self, dx: i32, dy: i32, buttons: u32, wheel: f32) {
if dx != 0 || dy != 0 {
unsafe { send_mouse(dx, dy, 0, MOUSEEVENTF_MOVE) };
}
if buttons != self.buttons {
self.mouse_buttons(buttons, self.buttons);
self.buttons = buttons;
}
if wheel != 0.0 {
self.wheel(wheel);
}
}
/// Inject a string as literal Unicode (translate / international-keyboard
/// mode): each UTF-16 code unit is sent via KEYEVENTF_UNICODE, so the
/// exact character is typed regardless of the host's keyboard layout —
/// this is how AltGr combos, dead-key compositions and non-US layouts
/// come through correctly (the viewer's OS already resolved them to a
/// character). No VK/scan is used, so it is layout-independent.
pub fn text(&self, s: &str) {
for u in s.encode_utf16() {
unsafe {
send_key(VIRTUAL_KEY(0), u, KEYEVENTF_UNICODE);
send_key(VIRTUAL_KEY(0), u, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP);
}
}
}
/// Release any held buttons (viewer left / session end), so nothing stays
/// stuck down.
pub fn release_buttons(&mut self) {
if self.buttons != 0 {
self.mouse_buttons(0, self.buttons);
self.buttons = 0;
}
}
fn mouse_move(&self, x: i32, y: i32) {
unsafe {
let vl = GetSystemMetrics(SM_XVIRTUALSCREEN);
let vt = GetSystemMetrics(SM_YVIRTUALSCREEN);
let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1);
let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1);
let nx = ((x - vl) as i64 * 65535 / (vw - 1).max(1) as i64) as i32;
let ny = ((y - vt) as i64 * 65535 / (vh - 1).max(1) as i64) as i32;
send_mouse(
nx,
ny,
0,
MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK,
);
}
}
fn mouse_buttons(&self, new_b: u32, old_b: u32) {
let mut evts: Vec<(MOUSE_EVENT_FLAGS, i32)> = Vec::new();
for (mask, down, up) in [
(1u32, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
(2, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
(4, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
] {
if new_b & mask != 0 && old_b & mask == 0 {
evts.push((down, 0));
}
if new_b & mask == 0 && old_b & mask != 0 {
evts.push((up, 0));
}
}
// Side buttons: bit 8 = X1 (back), bit 16 = X2 (forward).
for (mask, xb) in [(8u32, 0x0001i32), (16u32, 0x0002i32)] {
if new_b & mask != 0 && old_b & mask == 0 {
evts.push((MOUSEEVENTF_XDOWN, xb));
}
if new_b & mask == 0 && old_b & mask != 0 {
evts.push((MOUSEEVENTF_XUP, xb));
}
}
for (flags, data) in evts {
unsafe { send_mouse(0, 0, (data as u32) << 16, flags) };
}
}
fn wheel(&self, rows: f32) {
unsafe {
send_mouse(0, 0, (rows * 120.0) as i32 as u32, MOUSEEVENTF_WHEEL);
}
}
/// Inject a key by DOM code + down/up. Unknown codes are ignored.
pub fn key(&self, code: &str, down: bool) {
let Some(vk) = code_to_vk(code) else {
return;
};
unsafe {
let scan = MapVirtualKeyW(vk as u32, MAPVK_VK_TO_VSC) as u16;
let mut flags = KEYEVENTF_SCANCODE;
if is_extended(code) {
flags |= KEYEVENTF_EXTENDEDKEY;
}
if !down {
flags |= KEYEVENTF_KEYUP;
}
if scan != 0 {
send_key(VIRTUAL_KEY(0), scan, flags);
} else {
// No scan code (rare) — fall back to the virtual key.
let mut f = KEYBD_EVENT_FLAGS(0);
if !down {
f |= KEYEVENTF_KEYUP;
}
send_key(VIRTUAL_KEY(vk), 0, f);
}
}
}
/// Horizontal wheel (not surfaced by the core event yet, but available).
#[allow(dead_code)]
fn hwheel(&self, rows: f32) {
unsafe {
send_mouse(0, 0, (rows * 120.0) as i32 as u32, MOUSEEVENTF_HWHEEL);
}
}
}
unsafe fn send_mouse(dx: i32, dy: i32, mouse_data: u32, flags: MOUSE_EVENT_FLAGS) {
let input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx,
dy,
mouseData: mouse_data,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
};
SendInput(&[input], std::mem::size_of::<INPUT>() as i32);
}
unsafe fn send_key(vk: VIRTUAL_KEY, scan: u16, flags: KEYBD_EVENT_FLAGS) {
let input = INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: vk,
wScan: scan,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
};
SendInput(&[input], std::mem::size_of::<INPUT>() as i32);
}
/// DOM `KeyboardEvent.code` -> Windows virtual-key. Mirrors the Python host so
/// every existing viewer keeps working unchanged.
fn code_to_vk(code: &str) -> Option<u16> {
if let Some(c) = code.strip_prefix("Key") {
let b = c.as_bytes();
if b.len() == 1 && b[0].is_ascii_uppercase() {
return Some(0x41 + (b[0] - b'A') as u16);
}
}
if let Some(d) = code.strip_prefix("Digit") {
let b = d.as_bytes();
if b.len() == 1 && b[0].is_ascii_digit() {
return Some(0x30 + (b[0] - b'0') as u16);
}
}
if let Some(d) = code.strip_prefix("Numpad") {
let b = d.as_bytes();
if b.len() == 1 && b[0].is_ascii_digit() {
return Some(0x60 + (b[0] - b'0') as u16);
}
}
if let Some(n) = code.strip_prefix('F') {
if let Ok(num) = n.parse::<u16>() {
if (1..=24).contains(&num) {
return Some(0x70 + num - 1);
}
}
}
Some(match code {
"Backquote" => 0xC0,
"Minus" => 0xBD,
"Equal" => 0xBB,
"BracketLeft" => 0xDB,
"BracketRight" => 0xDD,
"Backslash" => 0xDC,
"Semicolon" => 0xBA,
"Quote" => 0xDE,
"Comma" => 0xBC,
"Period" => 0xBE,
"Slash" => 0xBF,
"IntlBackslash" => 0xE2,
"CapsLock" => 0x14,
"Space" => 0x20,
"Enter" | "NumpadEnter" => 0x0D,
"Backspace" => 0x08,
"Tab" => 0x09,
"Escape" => 0x1B,
"Delete" => 0x2E,
"Insert" => 0x2D,
"Home" => 0x24,
"End" => 0x23,
"PageUp" => 0x21,
"PageDown" => 0x22,
"ArrowLeft" => 0x25,
"ArrowUp" => 0x26,
"ArrowRight" => 0x27,
"ArrowDown" => 0x28,
"ControlLeft" => 0xA2,
"ControlRight" => 0xA3,
"AltLeft" => 0xA4,
"AltRight" => 0xA5,
"ShiftLeft" => 0xA0,
"ShiftRight" => 0xA1,
"MetaLeft" => 0x5B,
"MetaRight" => 0x5C,
"ContextMenu" => 0x5D,
"PrintScreen" => 0x2C,
"ScrollLock" => 0x91,
"Pause" => 0x13,
"NumLock" => 0x90,
"NumpadMultiply" => 0x6A,
"NumpadAdd" => 0x6B,
"NumpadSubtract" => 0x6D,
"NumpadDecimal" => 0x6E,
"NumpadDivide" => 0x6F,
// Media / volume keys (DOM UI Events code values) -> VK_* media codes, so
// a viewer that grabs them (system-key hook) drives the host's volume,
// playback and browser the same as sitting at the machine.
"AudioVolumeMute" => 0xAD,
"AudioVolumeDown" => 0xAE,
"AudioVolumeUp" => 0xAF,
"MediaTrackNext" => 0xB0,
"MediaTrackPrevious" => 0xB1,
"MediaStop" => 0xB2,
"MediaPlayPause" => 0xB3,
"LaunchMail" => 0xB4,
"MediaSelect" | "LaunchMediaPlayer" => 0xB5,
"LaunchApp1" => 0xB6,
"LaunchApp2" => 0xB7,
"BrowserBack" => 0xA6,
"BrowserForward" => 0xA7,
"BrowserRefresh" => 0xA8,
"BrowserStop" => 0xA9,
"BrowserSearch" => 0xAA,
"BrowserFavorites" => 0xAB,
"BrowserHome" => 0xAC,
_ => return None,
})
}
fn is_extended(code: &str) -> bool {
matches!(
code,
"ArrowRight"
| "ArrowLeft"
| "ArrowUp"
| "ArrowDown"
| "PageUp"
| "PageDown"
| "Home"
| "End"
| "Insert"
| "Delete"
| "NumLock"
| "PrintScreen"
| "Pause"
| "ControlRight"
| "AltRight"
| "MetaLeft"
| "MetaRight"
| "ContextMenu"
| "NumpadEnter"
| "NumpadDivide"
| "ScrollLock"
// media / volume / browser keys are extended
| "AudioVolumeMute"
| "AudioVolumeDown"
| "AudioVolumeUp"
| "MediaTrackNext"
| "MediaTrackPrevious"
| "MediaStop"
| "MediaPlayPause"
| "LaunchMail"
| "MediaSelect"
| "LaunchMediaPlayer"
| "LaunchApp1"
| "LaunchApp2"
| "BrowserBack"
| "BrowserForward"
| "BrowserRefresh"
| "BrowserStop"
| "BrowserSearch"
| "BrowserFavorites"
| "BrowserHome"
)
}
#[cfg(test)]
mod tests {
use super::code_to_vk;
#[test]
fn maps_media_and_volume_keys() {
// Volume + media-transport keys the viewer's system-key grab forwards.
assert_eq!(code_to_vk("AudioVolumeMute"), Some(0xAD));
assert_eq!(code_to_vk("AudioVolumeDown"), Some(0xAE));
assert_eq!(code_to_vk("AudioVolumeUp"), Some(0xAF));
assert_eq!(code_to_vk("MediaTrackNext"), Some(0xB0));
assert_eq!(code_to_vk("MediaTrackPrevious"), Some(0xB1));
assert_eq!(code_to_vk("MediaStop"), Some(0xB2));
assert_eq!(code_to_vk("MediaPlayPause"), Some(0xB3));
assert_eq!(code_to_vk("BrowserBack"), Some(0xA6));
assert_eq!(code_to_vk("BrowserHome"), Some(0xAC));
}
#[test]
fn maps_system_keys_the_hook_grabs() {
// The WH_KEYBOARD_LL grab forwards these; the host must inject them.
assert_eq!(code_to_vk("MetaLeft"), Some(0x5B)); // LWin
assert_eq!(code_to_vk("MetaRight"), Some(0x5C)); // RWin
assert_eq!(code_to_vk("PrintScreen"), Some(0x2C));
assert_eq!(code_to_vk("Tab"), Some(0x09));
assert_eq!(code_to_vk("Escape"), Some(0x1B));
}
#[test]
fn still_maps_ordinary_keys() {
assert_eq!(code_to_vk("KeyA"), Some(0x41));
assert_eq!(code_to_vk("Digit0"), Some(0x30));
assert_eq!(code_to_vk("F5"), Some(0x74));
assert_eq!(code_to_vk("Nonsense"), None);
}
}
#[cfg(test)]
mod rel_mouse_tests {
use super::*;
use windows::Win32::Foundation::POINT;
use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, SetCursorPos};
/// Real OS effect: a relative move must shift the ACTUAL cursor in the
/// commanded direction by a sane magnitude. Mirrors the e2e GetCursorPos
/// proof but as a focused, networkless unit test. The exact magnitude is
/// subject to the system pointer-speed/accel curve, so we assert direction
/// + a generous band around the sent (40, 30) rather than an exact match.
#[test]
fn mouse_rel_moves_cursor_relatively() {
unsafe {
let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(2);
let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(2);
// Park at center so the delta is not clamped at a screen edge.
let _ = SetCursorPos(vw / 2, vh / 2);
std::thread::sleep(std::time::Duration::from_millis(30));
let mut before = POINT::default();
let _ = GetCursorPos(&mut before);
let mut inj = Injector::new();
inj.mouse_rel(40, 30, 0, 0.0);
std::thread::sleep(std::time::Duration::from_millis(40));
let mut after = POINT::default();
let _ = GetCursorPos(&mut after);
let (ddx, ddy) = (after.x - before.x, after.y - before.y);
println!(
"REL_MOUSE_OK before=({},{}) after=({},{}) delta=({},{})",
before.x, before.y, after.x, after.y, ddx, ddy
);
// "Enhance pointer precision" (mouse acceleration) scales the whole
// relative vector by a system-dependent factor (seen 1.5x–3.2x on
// this box run-to-run), so assert the DIRECTION + the 40:30 ≈ 4:3
// ratio (preserved under uniform scaling) + sane bounds, never an
// absolute magnitude. 3*ddx ≈ 4*ddy proves the sent vector landed.
let ratio_ok = (3 * ddx - 4 * ddy).abs() <= (3 * ddx).max(1) / 4;
assert!(
ddx > 3 && ddy > 2 && ddx > ddy && ddx < 1000 && ratio_ok,
"relative move delta ({ddx},{ddy}) must track the sent (40,30) vector \
(right+down, ratio ~4:3), not stuck/absolute"
);
let _ = SetCursorPos(vw / 2, vh / 2);
}
}
}
#[cfg(test)]
mod translate_kbd_tests {
use super::*;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM};
use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, DispatchMessageW, PeekMessageW, SetWindowsHookExW, TranslateMessage,
UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSG, PM_REMOVE, WH_KEYBOARD_LL, WM_KEYDOWN,
};
// (vkCode, scanCode) captured on each keydown the LL hook sees.
static CAP: OnceLock<Mutex<Vec<(u32, u32)>>> = OnceLock::new();
unsafe extern "system" fn cap_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if code == 0 && wparam.0 as u32 == WM_KEYDOWN {
let kb = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
if let Some(m) = CAP.get() {
if let Ok(mut v) = m.lock() {
v.push((kb.vkCode, kb.scanCode));
}
}
}
CallNextHookEx(HHOOK::default(), code, wparam, lparam)
}
fn pump(ms: u64) {
let end = Instant::now() + Duration::from_millis(ms);
while Instant::now() < end {
unsafe {
let mut msg = MSG::default();
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
std::thread::sleep(Duration::from_millis(5));
}
}
/// Real OS effect, focus-INDEPENDENT: Injector::text injects via
/// KEYEVENTF_UNICODE, which a WH_KEYBOARD_LL hook sees as VK_PACKET (0xE7)
/// with the UTF-16 code unit in `scanCode`. So "Ae-acute" ("A" + U+00E9)
/// must produce keydown packets 0x41 then 0xE9 — proving the exact character
/// is injected regardless of the host keyboard layout (the point of
/// translate mode).
#[test]
fn text_injects_unicode_as_vk_packet() {
const VK_PACKET: u32 = 0xE7;
let _ = CAP.set(Mutex::new(Vec::new()));
let hook = unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(cap_proc), None, 0) }
.expect("install LL hook");
Injector::new().text("A\u{00e9}");
pump(400);
unsafe {
let _ = UnhookWindowsHookEx(hook);
}
let got = CAP.get().unwrap().lock().unwrap().clone();
let packets: Vec<u32> = got
.iter()
.filter(|(vk, _)| *vk == VK_PACKET)
.map(|(_, sc)| *sc)
.collect();
println!("TRANSLATE_KBD_OK keydown unicode packets = {:04X?}", packets);
assert_eq!(
packets,
vec![0x41u32, 0xE9],
"expected Unicode packets for 'A','e-acute'"
);
}
}
+216
View File
@@ -0,0 +1,216 @@
//! System-key grab for the viewer — a low-level keyboard hook (WH_KEYBOARD_LL)
//! that catches the keys Windows normally eats before an app can see them: the
//! Windows keys, PrintScreen, and the Alt+Tab / Alt+Esc / Ctrl+Esc task-switch
//! combos. While [`set_active`]`(true)` (the viewer calls this when a session is
//! live AND its window is focused), each grabbed key is forwarded to the host
//! (as a DOM `code`, through the install() channel) and the LOCAL action is
//! SUPPRESSED — so pressing Alt+Tab or the Windows key switches/opens on the
//! REMOTE machine instead of this one (RustDesk's "transmit system keys").
//!
//! The hook is process-global but only acts while active, so it is inert when no
//! session is focused. It lives in this shared crate (next to input.rs's
//! injection) so it is unit-testable; only the viewer installs it.
#![cfg(windows)]
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Mutex, OnceLock};
use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM};
use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_CONTROL, VK_MENU};
use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, WH_KEYBOARD_LL,
WM_KEYDOWN, WM_SYSKEYDOWN,
};
static ACTIVE: AtomicBool = AtomicBool::new(false);
static SINK: OnceLock<Sender<(&'static str, bool)>> = OnceLock::new();
static RX: OnceLock<Mutex<Receiver<(&'static str, bool)>>> = OnceLock::new();
static HOOK: AtomicIsize = AtomicIsize::new(0);
/// Turn grabbing on/off. The viewer sets this to `session_active && focused`
/// every frame; when false the hook passes everything through untouched.
pub fn set_active(on: bool) {
ACTIVE.store(on, Ordering::Relaxed);
}
/// Whether the grab is currently installed + active.
pub fn is_active() -> bool {
ACTIVE.load(Ordering::Relaxed)
}
/// The DOM `code` the host's `code_to_vk` expects for a grabbed virtual key.
fn system_code(vk: u16) -> Option<&'static str> {
Some(match vk {
0x5B => "MetaLeft", // Left Windows
0x5C => "MetaRight", // Right Windows
0x2C => "PrintScreen", // PrtScn
0x09 => "Tab", // only grabbed with Alt down (Alt+Tab)
0x1B => "Escape", // only grabbed with Alt/Ctrl down (Alt/Ctrl+Esc)
_ => return None,
})
}
fn alt_down() -> bool {
unsafe { (GetAsyncKeyState(VK_MENU.0 as i32) as u16 & 0x8000) != 0 }
}
fn ctrl_down() -> bool {
unsafe { (GetAsyncKeyState(VK_CONTROL.0 as i32) as u16 & 0x8000) != 0 }
}
/// Grab the Windows keys + PrtScn unconditionally; Tab/Esc only as a task-switch
/// combo (so ordinary Tab/Esc still go through egui normally).
fn should_grab(vk: u16) -> bool {
match vk {
0x5B | 0x5C | 0x2C => true,
0x09 => alt_down(), // Alt+Tab
0x1B => alt_down() || ctrl_down(), // Alt+Esc / Ctrl+Esc
_ => false,
}
}
unsafe extern "system" fn proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
// HC_ACTION == 0: the struct is valid and we may inspect/act on it.
if code == 0 && ACTIVE.load(Ordering::Relaxed) {
let kb = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
let vk = kb.vkCode as u16;
if should_grab(vk) {
if let Some(code_str) = system_code(vk) {
let down = matches!(wparam.0 as u32, WM_KEYDOWN | WM_SYSKEYDOWN);
if let Some(tx) = SINK.get() {
let _ = tx.send((code_str, down));
}
return LRESULT(1); // swallow it locally — it goes to the remote
}
}
}
CallNextHookEx(HHOOK::default(), code, wparam, lparam)
}
/// Install the global low-level keyboard hook once (owns its own channel; drain
/// grabbed keys with [`poll`]). Returns false if the hook could not be
/// installed. Must be called from a thread that pumps messages (the viewer's UI
/// thread does); idempotent.
pub fn install() -> bool {
if HOOK.load(Ordering::Relaxed) != 0 {
return true; // already installed
}
let (tx, rx) = std::sync::mpsc::channel();
let _ = SINK.set(tx);
let _ = RX.set(Mutex::new(rx));
match unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(proc), None, 0) } {
Ok(h) => {
HOOK.store(h.0 as isize, Ordering::Relaxed);
true
}
Err(e) => {
log::warn!("keyhook: SetWindowsHookExW failed: {e}");
false
}
}
}
/// Drain the keys grabbed since the last call, as `(dom_code, down)`. The viewer
/// calls this each frame and forwards each to the host via `Session::key`.
pub fn poll() -> Vec<(&'static str, bool)> {
let mut out = Vec::new();
if let Some(m) = RX.get() {
if let Ok(rx) = m.lock() {
while let Ok(ev) = rx.try_recv() {
out.push(ev);
}
}
}
out
}
/// Remove the hook (process exit also removes it).
pub fn uninstall() {
let raw = HOOK.swap(0, Ordering::Relaxed);
if raw != 0 {
unsafe {
let _ = UnhookWindowsHookEx(HHOOK(raw as *mut core::ffi::c_void));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP,
VIRTUAL_KEY,
};
use windows::Win32::UI::WindowsAndMessaging::{
DispatchMessageW, PeekMessageW, TranslateMessage, MSG, PM_REMOVE,
};
unsafe fn send_vk(vk: u16, down: bool) {
let mut flags = KEYBD_EVENT_FLAGS(0);
if !down {
flags |= KEYEVENTF_KEYUP;
}
let input = INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: VIRTUAL_KEY(vk),
wScan: 0,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
};
SendInput(&[input], std::mem::size_of::<INPUT>() as i32);
}
fn pump(ms: u64) {
let end = Instant::now() + Duration::from_millis(ms);
while Instant::now() < end {
unsafe {
let mut msg = MSG::default();
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
std::thread::sleep(Duration::from_millis(10));
}
}
// One test (the hook + ACTIVE are process-global, so we must not run two in
// parallel). Injected keys ARE delivered to WH_KEYBOARD_LL hooks, so we prove
// the grab end to end: while ACTIVE, injecting the Left Windows key is
// forwarded as "MetaLeft" down+up (and suppressed locally, returning 1);
// while INACTIVE, nothing is grabbed (keys pass through to the app).
#[test]
fn grabs_system_keys_only_while_active() {
assert!(install(), "hook should install");
// ACTIVE: the Windows key is grabbed + forwarded.
set_active(true);
unsafe {
send_vk(0x5B, true);
send_vk(0x5B, false);
}
pump(400);
let got = poll();
assert!(
got.contains(&("MetaLeft", true)) && got.contains(&("MetaLeft", false)),
"LWin should be grabbed as MetaLeft down+up while active; got {got:?}"
);
// INACTIVE: the same key is passed through, grabbed nothing.
set_active(false);
unsafe {
send_vk(0x5B, true);
send_vk(0x5B, false);
}
pump(200);
uninstall();
assert!(poll().is_empty(), "nothing should be grabbed while inactive");
}
}
+58
View File
@@ -0,0 +1,58 @@
//! netdesk-host — the native Rust host engine: DXGI screen capture + H.264
//! encode + SendInput injection, wired to `netdesk_core::host::run_host`. This
//! replaces the Python host so the Windows app is one unified Rust binary
//! (host + viewer). Developed by Akronlogic.
#[cfg(windows)]
pub mod capture;
pub mod encode;
pub mod color;
#[cfg(windows)]
pub mod encode_mf;
#[cfg(windows)]
pub mod input;
#[cfg(windows)]
pub mod sys;
#[cfg(windows)]
pub mod cursor;
pub mod encoder;
#[cfg(windows)]
pub mod audio;
pub mod files;
#[cfg(windows)]
pub mod qos;
pub mod run;
#[cfg(windows)]
pub mod telemetry;
#[cfg(windows)]
pub mod service;
pub mod wol;
#[cfg(windows)]
pub mod term;
#[cfg(windows)]
pub mod annot;
pub mod privacy;
#[cfg(windows)]
pub mod keyhook;
/// Make this process per-monitor DPI aware so DXGI capture returns real
/// physical pixels (not a virtualized, scaled desktop). Safe to call once at
/// startup; a no-op on non-Windows.
#[cfg(windows)]
pub fn set_dpi_aware() {
use windows::Win32::UI::HiDpi::{
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
};
unsafe {
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
}
}
#[cfg(not(windows))]
pub fn set_dpi_aware() {}
+131
View File
@@ -0,0 +1,131 @@
//! Driver-free privacy mode. A fullscreen, topmost, opaque-black overlay marked
//! `WDA_EXCLUDEFROMCAPTURE`, so:
//! - the LOCAL monitor(s) show solid black — a bystander at the host sees
//! nothing of what the remote operator is doing;
//! - the host's OWN screen capture (DXGI Desktop Duplication + the GDI BitBlt
//! fallback) EXCLUDES the overlay, so the remote operator still sees and
//! controls the real desktop behind it.
//! The overlay is also click-through + non-activating (WS_EX_TRANSPARENT |
//! WS_EX_NOACTIVATE), so the operator's injected SendInput passes straight
//! through to the real windows; blocking the LOCAL keyboard/mouse is the
//! separate `block_input` toggle. No virtual-display driver is needed — verified
//! by the `netdesk-privacy-probe` bin (WDA_EXCLUDEFROMCAPTURE hid a window from
//! both DXGI and GDI capture, 197600 -> 0 magenta pixels).
//!
//! The overlay lives on its own thread with a message pump (a window must be
//! serviced on its creating thread); dropping [`Privacy`] tears it down and
//! restores the screen.
#![cfg(windows)]
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;
use windows::core::w;
use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::Graphics::Gdi::CreateSolidBrush;
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetSystemMetrics,
PeekMessageW, RegisterClassW, SetLayeredWindowAttributes, SetWindowDisplayAffinity, ShowWindow,
TranslateMessage, LWA_ALPHA, MSG, PM_REMOVE, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN,
SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_SHOWNA, WDA_EXCLUDEFROMCAPTURE, WNDCLASSW,
WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_POPUP,
WS_VISIBLE,
};
unsafe extern "system" fn wndproc(h: HWND, m: u32, w: WPARAM, l: LPARAM) -> LRESULT {
DefWindowProcW(h, m, w, l)
}
/// A live privacy-mode blackout. Drop it to restore the screen.
pub struct Privacy {
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl Privacy {
/// Raise the blackout overlay (returns once the window thread has started).
pub fn start() -> Privacy {
let stop = Arc::new(AtomicBool::new(false));
let s2 = stop.clone();
let thread = std::thread::Builder::new()
.name("netdesk-privacy".into())
.spawn(move || unsafe { run(s2) })
.ok();
Privacy { stop, thread }
}
}
impl Drop for Privacy {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
unsafe fn run(stop: Arc<AtomicBool>) {
let hinst = HINSTANCE::default();
let brush = CreateSolidBrush(COLORREF(0x0000_0000)); // solid black
let class = w!("NdPrivacyBlackout");
let wc = WNDCLASSW {
lpfnWndProc: Some(wndproc),
hInstance: hinst,
lpszClassName: class,
hbrBackground: brush,
..Default::default()
};
// 0 if the class is already registered (a prior toggle this process) — fine.
let _ = RegisterClassW(&wc);
// Cover the whole virtual desktop (all monitors).
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
let y = GetSystemMetrics(SM_YVIRTUALSCREEN);
let cx = GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1);
let cy = GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1);
let hwnd = match CreateWindowExW(
WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_NOACTIVATE,
class,
w!("nd-privacy"),
WS_POPUP | WS_VISIBLE,
x,
y,
cx,
cy,
None,
None,
hinst,
None,
) {
Ok(h) => h,
Err(e) => {
log::warn!("privacy: window create failed: {e}");
return;
}
};
// Opaque (alpha 255) so the overlay is solid black on the physical screen.
let _ = SetLayeredWindowAttributes(hwnd, COLORREF(0), 255, LWA_ALPHA);
// Exclude from THIS host's own capture so the remote still sees the desktop.
if let Err(e) = SetWindowDisplayAffinity(hwnd, WDA_EXCLUDEFROMCAPTURE) {
log::warn!("privacy: SetWindowDisplayAffinity failed: {e}");
}
// Show without activating (keyboard focus stays on the real foreground app,
// so injected keystrokes still land there).
let _ = ShowWindow(hwnd, SW_SHOWNA);
log::info!("privacy: blackout up ({cx}x{cy} @ {x},{y}); excluded from capture, click-through");
let mut msg = MSG::default();
while !stop.load(Ordering::Relaxed) {
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
std::thread::sleep(Duration::from_millis(30));
}
let _ = DestroyWindow(hwnd);
log::info!("privacy: blackout down; screen restored");
}
+365
View File
@@ -0,0 +1,365 @@
//! Adaptive quality controller — the smoothness engine. A faithful adaptation
//! of RustDesk's `src/server/video_qos.rs` (delay-threshold ABR + FPS/scale
//! backoff with congestion confirmation and gradual recovery), distilled to a
//! pure, deterministic state machine the host feeds with viewer telemetry.
//!
//! The viewer reports, ~once a second, how far behind it is — a `delay` (how
//! long frames queue/decode in front of it) plus the transport RTT and packet
//! loss. [`Qos::on_sample`] turns that into the next `(fps, bitrate, scale)` for
//! the live encoder (the same knobs the manual `set.quality` lever drives). The
//! user's manual quality is the CEILING; the controller only ever trims BELOW it
//! under congestion and restores back toward it when the link is healthy.
//!
//! Priority mirrors RustDesk: **reduce bitrate first** (keep motion smooth),
//! then frame-rate (down to a floor), then resolution scale as the last resort;
//! recover in the reverse-ish order (frame-rate, then bitrate, then full
//! resolution). Congestion must be CONFIRMED (several bad samples in a row)
//! before cutting, so a single jittery reply does not thrash the stream.
/// A good-vs-bad network boundary: frames more than this far behind mean the
/// link cannot keep up (RustDesk's `DELAY_THRESHOLD_150MS`).
pub const DELAY_THRESHOLD_MS: u32 = 150;
/// Packet loss above this also counts as congestion.
pub const LOSS_THRESHOLD: f32 = 0.05;
/// Consecutive bad samples required before a reduction (confirm, don't thrash).
const BAD_CONFIRM: u8 = 3;
/// Consecutive good samples required before a step of recovery.
const GOOD_CONFIRM: u8 = 3;
/// Multiplicative bitrate cut on MILD congestion (keep 80%). Severe congestion
/// cuts harder (see [`severity_cut`]) so the stream sheds load fast when it is
/// badly behind, then trims gently near the threshold — RustDesk's graduated
/// backoff rather than one fixed step.
const BITRATE_CUT: f32 = 0.8;
/// Bitrate retention factor for how far past the delay/loss threshold a sample
/// is: gentle (0.8) just over the line, aggressive (0.6) when badly congested.
fn severity_cut(delay_ms: u32, loss: f32, threshold_ms: u32) -> f32 {
let t = threshold_ms.max(1);
if delay_ms >= t * 3 || loss >= 0.15 {
0.6
} else if delay_ms >= t * 2 || loss >= 0.10 {
0.7
} else {
BITRATE_CUT
}
}
/// FPS step down/up.
const FPS_STEP: u32 = 5;
/// Scale step down/up (resolution), last resort.
const SCALE_STEP: f32 = 0.1;
/// Startup frame-rate the ramp begins at (RustDesk-style slow start).
pub const INIT_FPS: u32 = 15;
/// How long the startup ramp takes to climb from [`INIT_FPS`] to the target.
pub const RAMP_MS: u64 = 3000;
/// Startup FPS ramp: a fresh session begins at [`INIT_FPS`] and climbs linearly
/// to the target over the first [`RAMP_MS`], so the initial keyframe + buffer
/// fill don't arrive as a full-rate burst that stutters before the decode /
/// jitter pipeline has warmed up. After [`RAMP_MS`] it returns the target
/// unchanged (no limiting), and a target already at/below [`INIT_FPS`] is never
/// slowed. Pure + deterministic; the capture loop caps its pacing fps with this.
pub fn startup_fps(elapsed_ms: u64, target_fps: u32) -> u32 {
let target = target_fps.max(1);
if elapsed_ms >= RAMP_MS || target <= INIT_FPS {
return target;
}
// Linear climb INIT_FPS -> target across RAMP_MS.
let span = (target - INIT_FPS) as u64;
let add = (span * elapsed_ms / RAMP_MS) as u32;
(INIT_FPS + add).min(target)
}
/// Static bounds + the user's manual ceiling. `ceil_*` are the manual quality
/// (the maximum the controller will restore to).
#[derive(Debug, Clone, Copy)]
pub struct QosConfig {
pub ceil_fps: u32,
pub ceil_bitrate: u32, // kbps
pub min_fps: u32,
pub min_bitrate: u32, // kbps
pub min_scale: f32,
pub delay_threshold_ms: u32,
}
impl QosConfig {
/// Defaults around a 1080p/30fps/6Mbps session, floors that stay usable on a
/// bad link (5fps, 400kbps, half resolution).
pub fn new(ceil_fps: u32, ceil_bitrate: u32) -> Self {
Self {
ceil_fps: ceil_fps.max(1),
ceil_bitrate: ceil_bitrate.max(1),
min_fps: 5,
min_bitrate: 400,
min_scale: 0.5,
delay_threshold_ms: DELAY_THRESHOLD_MS,
}
}
}
/// What the controller wants the encoder set to right now.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct QosTarget {
pub fps: u32,
pub bitrate: u32, // kbps
pub scale: f32,
}
/// The adaptive controller. Pure + deterministic: feed samples, read targets.
#[derive(Debug, Clone)]
pub struct Qos {
cfg: QosConfig,
fps: u32,
bitrate: u32,
scale: f32,
bad_run: u8,
good_run: u8,
}
impl Qos {
/// Start at the user's ceiling (full quality) — the controller only trims
/// below it under measured congestion.
pub fn new(cfg: QosConfig) -> Self {
Self {
fps: cfg.ceil_fps,
bitrate: cfg.ceil_bitrate,
scale: 1.0,
cfg,
bad_run: 0,
good_run: 0,
}
}
/// The current target.
pub fn target(&self) -> QosTarget {
QosTarget {
fps: self.fps,
bitrate: self.bitrate,
scale: self.scale,
}
}
/// Raise the ceiling (the user moved the manual quality lever). Current
/// values are re-clamped under the new ceiling; a raised ceiling lets the
/// controller recover higher.
pub fn set_ceiling(&mut self, ceil_fps: u32, ceil_bitrate: u32) {
self.cfg.ceil_fps = ceil_fps.max(1);
self.cfg.ceil_bitrate = ceil_bitrate.max(1);
self.fps = self.fps.min(self.cfg.ceil_fps);
self.bitrate = self.bitrate.min(self.cfg.ceil_bitrate);
}
/// Feed one telemetry sample (delay in ms, packet loss 0..1). Returns the
/// new target. Congestion is confirmed over [`BAD_CONFIRM`] samples before a
/// cut; recovery takes [`GOOD_CONFIRM`] good samples per step.
pub fn on_sample(&mut self, delay_ms: u32, loss: f32) -> QosTarget {
let congested = delay_ms >= self.cfg.delay_threshold_ms || loss >= LOSS_THRESHOLD;
if congested {
self.good_run = 0;
self.bad_run = self.bad_run.saturating_add(1);
if self.bad_run >= BAD_CONFIRM {
let cut = severity_cut(delay_ms, loss, self.cfg.delay_threshold_ms);
self.reduce(cut);
self.bad_run = 0; // require fresh confirmation before the next cut
}
} else {
self.bad_run = 0;
self.good_run = self.good_run.saturating_add(1);
if self.good_run >= GOOD_CONFIRM {
self.grow();
self.good_run = 0;
}
}
self.target()
}
/// Trim one step: bitrate first (keep motion), then fps, then resolution.
/// `cut` is the bitrate retention factor for this sample's severity.
fn reduce(&mut self, cut: f32) {
if self.bitrate > self.cfg.min_bitrate {
let next = (self.bitrate as f32 * cut) as u32;
self.bitrate = next.max(self.cfg.min_bitrate);
} else if self.fps > self.cfg.min_fps {
self.fps = self.fps.saturating_sub(FPS_STEP).max(self.cfg.min_fps);
} else if self.scale > self.cfg.min_scale + 1e-3 {
self.scale = (self.scale - SCALE_STEP).max(self.cfg.min_scale);
}
}
/// Restore one step toward the ceiling: full resolution, then frame-rate,
/// then bitrate.
fn grow(&mut self) {
if self.scale < 1.0 - 1e-3 {
self.scale = (self.scale + SCALE_STEP).min(1.0);
} else if self.fps < self.cfg.ceil_fps {
self.fps = (self.fps + FPS_STEP).min(self.cfg.ceil_fps);
} else if self.bitrate < self.cfg.ceil_bitrate {
// +≤ a quarter of the ceiling per step, like RustDesk's capped ramp.
let step = (self.cfg.ceil_bitrate / 4).max(200);
self.bitrate = (self.bitrate + step).min(self.cfg.ceil_bitrate);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn qos() -> Qos {
Qos::new(QosConfig::new(30, 6000))
}
#[test]
fn starts_at_ceiling() {
let t = qos().target();
assert_eq!((t.fps, t.bitrate, t.scale), (30, 6000, 1.0));
}
#[test]
fn healthy_link_stays_at_ceiling() {
let mut q = qos();
for _ in 0..50 {
q.on_sample(20, 0.0);
}
let t = q.target();
assert_eq!((t.fps, t.bitrate, t.scale), (30, 6000, 1.0));
}
#[test]
fn single_bad_sample_does_not_cut() {
let mut q = qos();
q.on_sample(300, 0.0); // one bad reply
assert_eq!(q.target().bitrate, 6000, "must confirm congestion before cutting");
}
#[test]
fn confirmed_congestion_cuts_bitrate_first() {
let mut q = qos();
for _ in 0..BAD_CONFIRM {
q.on_sample(400, 0.0);
}
let t = q.target();
assert!(t.bitrate < 6000, "bitrate cut first");
assert_eq!(t.fps, 30, "fps untouched while bitrate has room");
assert_eq!(t.scale, 1.0, "scale untouched while bitrate has room");
}
#[test]
fn heavy_congestion_walks_bitrate_then_fps_then_scale() {
let mut q = qos();
for _ in 0..400 {
q.on_sample(500, 0.1); // relentless congestion
}
let t = q.target();
assert_eq!(t.bitrate, 400, "bitrate at floor");
assert_eq!(t.fps, 5, "fps at floor");
assert!((t.scale - 0.5).abs() < 1e-3, "scale at floor");
}
#[test]
fn severe_congestion_cuts_harder_than_mild() {
// One confirmed cut at severe delay (>=3x threshold) must drop bitrate
// further than one confirmed cut just over the threshold.
let mut mild = qos();
for _ in 0..BAD_CONFIRM {
mild.on_sample(DELAY_THRESHOLD_MS + 5, 0.0); // ~threshold -> 0.8
}
let mut severe = qos();
for _ in 0..BAD_CONFIRM {
severe.on_sample(DELAY_THRESHOLD_MS * 4, 0.0); // deep -> 0.6
}
assert_eq!(mild.target().bitrate, (6000.0 * 0.8) as u32);
assert_eq!(severe.target().bitrate, (6000.0 * 0.6) as u32);
assert!(
severe.target().bitrate < mild.target().bitrate,
"severe congestion should shed more bitrate per cut"
);
}
#[test]
fn loss_alone_triggers_congestion() {
let mut q = qos();
for _ in 0..BAD_CONFIRM {
q.on_sample(10, 0.2); // low delay but 20% loss
}
assert!(q.target().bitrate < 6000, "packet loss counts as congestion");
}
#[test]
fn recovers_toward_ceiling_after_congestion() {
let mut q = qos();
for _ in 0..400 {
q.on_sample(500, 0.1);
}
assert!(q.target().bitrate <= 400);
// link heals
for _ in 0..1000 {
q.on_sample(20, 0.0);
}
let t = q.target();
assert_eq!(t.fps, 30, "fps recovered to ceiling");
assert_eq!(t.bitrate, 6000, "bitrate recovered to ceiling");
assert!((t.scale - 1.0).abs() < 1e-3, "scale recovered to full");
}
#[test]
fn never_exceeds_ceiling_on_recovery() {
let mut q = qos();
for _ in 0..5000 {
q.on_sample(5, 0.0);
}
let t = q.target();
assert!(t.fps <= 30 && t.bitrate <= 6000 && t.scale <= 1.0);
}
#[test]
fn raising_ceiling_lets_it_climb() {
let mut q = Qos::new(QosConfig::new(15, 3000));
for _ in 0..200 {
q.on_sample(10, 0.0);
}
assert_eq!(q.target().fps, 15);
q.set_ceiling(60, 12000);
for _ in 0..1000 {
q.on_sample(10, 0.0);
}
assert_eq!(q.target().fps, 60, "climbs to the raised ceiling");
}
#[test]
fn startup_ramp_begins_at_init_fps() {
assert_eq!(startup_fps(0, 60), INIT_FPS, "a fresh session starts at the ramp floor");
assert_eq!(startup_fps(0, 30), INIT_FPS);
}
#[test]
fn startup_ramp_reaches_target_by_ramp_ms() {
assert_eq!(startup_fps(RAMP_MS, 60), 60, "full target once the ramp window elapses");
assert_eq!(startup_fps(RAMP_MS + 1000, 60), 60, "stays at target after the ramp");
}
#[test]
fn startup_ramp_is_monotonic_nondecreasing() {
let mut prev = 0;
for ms in (0..=RAMP_MS).step_by(100) {
let f = startup_fps(ms, 60);
assert!(f >= prev, "ramp must never step backward (ms={ms})");
assert!(f >= INIT_FPS && f <= 60, "ramp stays within [INIT_FPS, target]");
prev = f;
}
}
#[test]
fn startup_ramp_midpoint_is_between_floor_and_target() {
let mid = startup_fps(RAMP_MS / 2, 60);
assert!(mid > INIT_FPS && mid < 60, "half-way through, fps is between floor and target (got {mid})");
}
#[test]
fn startup_ramp_never_slows_a_low_target() {
// A target already at/below the floor is served at full rate immediately.
assert_eq!(startup_fps(0, 15), 15);
assert_eq!(startup_fps(0, 10), 10);
assert_eq!(startup_fps(500, 5), 5);
}
}
File diff suppressed because it is too large. Load diff
+289
View File
@@ -0,0 +1,289 @@
//! Windows SYSTEM service for UNATTENDED access + lock/login-screen capture.
//!
//! The service runs as LocalSystem (session 0) and spawns the NetDesk host INTO
//! the active console session **as SYSTEM**, with its thread desktop following
//! the current input desktop. That lets the host capture and inject on the
//! secure desktop (the lock screen, the Windows sign-in screen, and UAC) — so a
//! remote viewer can sign in and unlock an unattended machine, TeamViewer-style.
//!
//! Opt-in and isolated: nothing here runs unless `--install-service` is executed
//! elevated. The ordinary user-session host (Desktop autostart / admin task) is
//! untouched, so installing or removing the service never risks the path that
//! already works.
//!
//! Entry points (wired in the viewer binary's `main`):
//! --install-service register + start the service (self-elevates via UAC)
//! --uninstall-service stop + delete the service
//! --service-run the SCM service entry (run by Windows, not by hand)
//! --session-agent the per-session host the service spawns (follows the
//! input desktop so the lock screen is captured)
#![cfg(windows)]
use anyhow::{anyhow, Result};
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering};
use windows::core::{PCWSTR, PWSTR};
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
use windows::Win32::Security::{
AdjustTokenPrivileges, DuplicateTokenEx, LookupPrivilegeValueW, SetTokenInformation,
SecurityIdentification, TokenPrimary, TokenSessionId, LUID_AND_ATTRIBUTES,
SE_PRIVILEGE_ENABLED, TOKEN_ACCESS_MASK, TOKEN_ADJUST_DEFAULT, TOKEN_ADJUST_PRIVILEGES,
TOKEN_ADJUST_SESSIONID, TOKEN_ASSIGN_PRIMARY, TOKEN_DUPLICATE, TOKEN_PRIVILEGES, TOKEN_QUERY,
};
use windows::Win32::System::RemoteDesktop::WTSGetActiveConsoleSessionId;
use windows::Win32::System::Services::{
RegisterServiceCtrlHandlerW, SetServiceStatus, StartServiceCtrlDispatcherW,
SERVICE_STATUS, SERVICE_STATUS_HANDLE, SERVICE_TABLE_ENTRYW, SERVICE_ACCEPT_STOP,
SERVICE_CONTROL_SHUTDOWN, SERVICE_CONTROL_STOP, SERVICE_RUNNING, SERVICE_START_PENDING,
SERVICE_STOP_PENDING, SERVICE_STOPPED, SERVICE_WIN32_OWN_PROCESS,
};
use windows::Win32::System::Threading::{
CreateProcessAsUserW, GetCurrentProcess, OpenProcessToken, TerminateProcess,
CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, PROCESS_INFORMATION, STARTUPINFOW,
};
const SERVICE_NAME: &str = "NetDeskHost";
static STOP: AtomicBool = AtomicBool::new(false);
static mut STATUS_HANDLE: isize = 0;
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
// ---------------------------------------------------------------------------
// install / uninstall (shell out to sc.exe; requires elevation)
// ---------------------------------------------------------------------------
/// Register the service (LocalSystem, auto-start, auto-restart) and start it.
pub fn install_service(exe: &str) -> Result<()> {
let bin = format!("\"{exe}\" --service-run");
// Create (quirk: sc wants "key=" and the value as separate tokens).
run_sc(&["create", SERVICE_NAME, "binPath=", &bin, "start=", "auto",
"obj=", "LocalSystem", "DisplayName=", "NetDesk Host (unattended)"])?;
let _ = run_sc(&["description", SERVICE_NAME,
"NetDesk unattended host — captures the lock/sign-in screen so you can access this PC remotely."]);
// Restart on crash so unattended access survives faults.
let _ = run_sc(&["failure", SERVICE_NAME, "reset=", "0", "actions=",
"restart/5000/restart/5000/restart/5000"]);
run_sc(&["start", SERVICE_NAME])?;
Ok(())
}
/// Stop and delete the service.
pub fn uninstall_service() -> Result<()> {
let _ = run_sc(&["stop", SERVICE_NAME]);
run_sc(&["delete", SERVICE_NAME])?;
Ok(())
}
fn run_sc(args: &[&str]) -> Result<()> {
let status = std::process::Command::new("sc")
.args(args)
.status()
.map_err(|e| anyhow!("sc {args:?}: {e}"))?;
if status.success() {
Ok(())
} else {
Err(anyhow!("sc {:?} exited with {:?}", args, status.code()))
}
}
// ---------------------------------------------------------------------------
// service entry (run by the SCM via `--service-run`)
// ---------------------------------------------------------------------------
/// Hand control to the Service Control Manager. Blocks until the service stops.
pub fn run_service() -> Result<()> {
let name = wide(SERVICE_NAME);
let table = [
SERVICE_TABLE_ENTRYW {
lpServiceName: PWSTR(name.as_ptr() as *mut u16),
lpServiceProc: Some(service_main),
},
SERVICE_TABLE_ENTRYW {
lpServiceName: PWSTR::null(),
lpServiceProc: None,
},
];
unsafe {
StartServiceCtrlDispatcherW(table.as_ptr())
.map_err(|e| anyhow!("StartServiceCtrlDispatcher: {e}"))?;
}
Ok(())
}
extern "system" fn service_main(_argc: u32, _argv: *mut PWSTR) {
unsafe {
let name = wide(SERVICE_NAME);
match RegisterServiceCtrlHandlerW(PCWSTR(name.as_ptr()), Some(handler)) {
Ok(h) => STATUS_HANDLE = h.0 as isize,
Err(_) => return,
}
set_status(SERVICE_START_PENDING, 0);
set_status(SERVICE_RUNNING, SERVICE_ACCEPT_STOP);
// Main loop: keep a host alive in the active console session.
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_default();
let mut child: Option<(HANDLE, u32)> = None;
while !STOP.load(Ordering::Relaxed) {
let session = WTSGetActiveConsoleSessionId();
let need_spawn = match child {
// 0xFFFFFFFF => no one attached to the console right now.
_ if session == 0xFFFF_FFFF => false,
None => true,
Some((_, sess)) => sess != session || !process_alive(child),
};
if need_spawn && session != 0xFFFF_FFFF {
if let Some((h, _)) = child.take() {
let _ = TerminateProcess(h, 1);
let _ = CloseHandle(h);
}
match spawn_host_in_session(session, &exe) {
Ok(h) => child = Some((h, session)),
Err(_) => {}
}
}
std::thread::sleep(std::time::Duration::from_millis(1500));
}
if let Some((h, _)) = child.take() {
let _ = TerminateProcess(h, 0);
let _ = CloseHandle(h);
}
set_status(SERVICE_STOPPED, 0);
}
}
extern "system" fn handler(control: u32) {
if control == SERVICE_CONTROL_STOP || control == SERVICE_CONTROL_SHUTDOWN {
STOP.store(true, Ordering::Relaxed);
unsafe { set_status(SERVICE_STOP_PENDING, 0) };
}
}
unsafe fn set_status(state: windows::Win32::System::Services::SERVICE_STATUS_CURRENT_STATE, accept: u32) {
if STATUS_HANDLE == 0 {
return;
}
let mut st = SERVICE_STATUS {
dwServiceType: SERVICE_WIN32_OWN_PROCESS,
dwCurrentState: state,
dwControlsAccepted: accept,
dwWin32ExitCode: 0,
dwServiceSpecificExitCode: 0,
dwCheckPoint: 0,
dwWaitHint: 3000,
};
let _ = SetServiceStatus(SERVICE_STATUS_HANDLE(STATUS_HANDLE as *mut c_void), &mut st);
}
fn process_alive(child: Option<(HANDLE, u32)>) -> bool {
use windows::Win32::Foundation::{STILL_ACTIVE, WAIT_OBJECT_0};
use windows::Win32::System::Threading::{GetExitCodeProcess, WaitForSingleObject};
match child {
None => false,
Some((h, _)) => unsafe {
// Fast path: a 0ms wait signals only if the process has exited.
if WaitForSingleObject(h, 0) == WAIT_OBJECT_0 {
return false;
}
let mut code = 0u32;
if GetExitCodeProcess(h, &mut code).is_ok() {
code == STILL_ACTIVE.0 as u32
} else {
false
}
},
}
}
// ---------------------------------------------------------------------------
// spawn the host as SYSTEM in the active session (so it can reach the secure
// desktop). Duplicates the service's SYSTEM token, re-targets it at the active
// session, and CreateProcessAsUser's the host on winsta0\default.
// ---------------------------------------------------------------------------
fn spawn_host_in_session(session: u32, exe: &str) -> Result<HANDLE> {
unsafe {
let proc = GetCurrentProcess();
let access = TOKEN_ACCESS_MASK(
TOKEN_DUPLICATE.0
| TOKEN_QUERY.0
| TOKEN_ASSIGN_PRIMARY.0
| TOKEN_ADJUST_DEFAULT.0
| TOKEN_ADJUST_SESSIONID.0
| TOKEN_ADJUST_PRIVILEGES.0,
);
let mut tok = HANDLE::default();
OpenProcessToken(proc, access, &mut tok).map_err(|e| anyhow!("OpenProcessToken: {e}"))?;
enable_privilege(tok, "SeTcbPrivilege");
let mut dup = HANDLE::default();
DuplicateTokenEx(
tok,
TOKEN_ACCESS_MASK(0x02000000), // MAXIMUM_ALLOWED
None,
SecurityIdentification,
TokenPrimary,
&mut dup,
)
.map_err(|e| anyhow!("DuplicateTokenEx: {e}"))?;
// Re-target the primary token at the active console session.
let sess = session;
SetTokenInformation(
dup,
TokenSessionId,
&sess as *const u32 as *const c_void,
std::mem::size_of::<u32>() as u32,
)
.map_err(|e| anyhow!("SetTokenInformation(session): {e}"))?;
let mut cmd = wide(&format!("\"{exe}\" --session-agent"));
let mut desktop = wide("winsta0\\default");
let mut si = STARTUPINFOW::default();
si.cb = std::mem::size_of::<STARTUPINFOW>() as u32;
si.lpDesktop = PWSTR(desktop.as_mut_ptr());
let mut pi = PROCESS_INFORMATION::default();
CreateProcessAsUserW(
dup,
PCWSTR::null(),
PWSTR(cmd.as_mut_ptr()),
None,
None,
false,
CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT,
None,
PCWSTR::null(),
&si,
&mut pi,
)
.map_err(|e| anyhow!("CreateProcessAsUser: {e}"))?;
let _ = CloseHandle(pi.hThread);
let _ = CloseHandle(dup);
let _ = CloseHandle(tok);
Ok(pi.hProcess)
}
}
fn enable_privilege(token: HANDLE, name: &str) {
unsafe {
let wname = wide(name);
let mut luid = LUID::default();
if LookupPrivilegeValueW(PCWSTR::null(), PCWSTR(wname.as_ptr()), &mut luid).is_err() {
return;
}
let tp = TOKEN_PRIVILEGES {
PrivilegeCount: 1,
Privileges: [LUID_AND_ATTRIBUTES {
Luid: luid,
Attributes: SE_PRIVILEGE_ENABLED,
}],
};
let _ = AdjustTokenPrivileges(token, false, Some(&tp), 0, None, None);
}
}
+319
View File
@@ -0,0 +1,319 @@
//! Host system controls (RustDesk/TeamViewer-grade): process list + kill,
//! block-local-input, and wallpaper hide/restore. Native Win32 via the windows
//! crate — no shelling out, no Python.
#![cfg(windows)]
use serde_json::{json, Value};
use std::ffi::c_void;
use std::os::windows::process::CommandExt;
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
};
use windows::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS};
use windows::Win32::System::Threading::{
OpenProcess, TerminateProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE,
};
use windows::Win32::UI::Input::KeyboardAndMouse::BlockInput;
use windows::Win32::UI::WindowsAndMessaging::{
SystemParametersInfoW, SPIF_UPDATEINIFILE, SPI_GETDESKWALLPAPER,
SPI_SETDESKWALLPAPER,
};
/// Running processes as `{name, pid, mem}` (mem in KB), heaviest first, capped
/// at 300 — the shape the viewer's process panel expects.
pub fn process_list() -> Vec<Value> {
let mut items = Vec::new();
unsafe {
let snap = match CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) {
Ok(h) => h,
Err(_) => return items,
};
let mut entry = PROCESSENTRY32W {
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
..Default::default()
};
if Process32FirstW(snap, &mut entry).is_ok() {
loop {
let len = entry
.szExeFile
.iter()
.position(|&c| c == 0)
.unwrap_or(entry.szExeFile.len());
let name = String::from_utf16_lossy(&entry.szExeFile[..len]);
let pid = entry.th32ProcessID;
let mem = process_mem_kb(pid);
items.push(json!({ "name": name, "pid": pid, "mem": mem }));
if Process32NextW(snap, &mut entry).is_err() {
break;
}
}
}
let _ = CloseHandle(snap);
}
items.sort_by_key(|i| std::cmp::Reverse(i.get("mem").and_then(|m| m.as_u64()).unwrap_or(0)));
items.truncate(300);
items
}
unsafe fn process_mem_kb(pid: u32) -> u64 {
if pid == 0 {
return 0;
}
let h = match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) {
Ok(h) => h,
Err(_) => return 0,
};
let mut c = PROCESS_MEMORY_COUNTERS::default();
let r = GetProcessMemoryInfo(h, &mut c, std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32);
let _ = CloseHandle(h);
if r.is_ok() {
(c.WorkingSetSize / 1024) as u64
} else {
0
}
}
/// Terminate a process by pid. Returns (ok, message).
pub fn process_kill(pid: u32) -> (bool, String) {
if pid == 0 {
return (false, "invalid pid".into());
}
unsafe {
match OpenProcess(PROCESS_TERMINATE, false, pid) {
Ok(h) => {
let r = TerminateProcess(h, 1);
let _ = CloseHandle(h);
match r {
Ok(()) => (true, format!("terminated {pid}")),
Err(e) => (false, format!("terminate failed: {e}")),
}
}
Err(e) => (false, format!("cannot open process {pid}: {e}")),
}
}
}
/// Block or unblock local keyboard/mouse so the person at the host cannot
/// interfere during a control session. Returns true on success.
pub fn set_block_input(on: bool) -> bool {
unsafe { BlockInput(on).is_ok() }
}
/// Current desktop wallpaper path (empty if none / unreadable).
pub fn wallpaper_get() -> String {
unsafe {
let mut buf = [0u16; 520];
if SystemParametersInfoW(
SPI_GETDESKWALLPAPER,
buf.len() as u32,
Some(buf.as_mut_ptr() as *mut c_void),
Default::default(),
)
.is_ok()
{
let len = buf.iter().position(|&c| c == 0).unwrap_or(0);
String::from_utf16_lossy(&buf[..len])
} else {
String::new()
}
}
}
/// Set the desktop wallpaper (empty string clears it to the background colour).
pub fn wallpaper_set(path: &str) -> bool {
unsafe {
let mut wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
SystemParametersInfoW(
SPI_SETDESKWALLPAPER,
0,
Some(wide.as_mut_ptr() as *mut c_void),
SPIF_UPDATEINIFILE,
)
.is_ok()
}
}
/// Windows process-creation flag: run without flashing a console window.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Run a shell command on the host and capture stdout+stderr (size-capped).
/// Runs with the host process's privileges — full Administrator/SYSTEM when the
/// host is elevated (service or run-as-admin), standard user otherwise. This is
/// the "run CMD / PowerShell on the remote machine" feature.
pub fn run_command(cmd: &str) -> (bool, String) {
let out = std::process::Command::new("cmd")
.args(["/c", cmd])
.creation_flags(CREATE_NO_WINDOW)
.output();
match out {
Ok(o) => {
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
let err = String::from_utf8_lossy(&o.stderr);
if !err.trim().is_empty() {
if !s.is_empty() {
s.push('\n');
}
s.push_str(&err);
}
if s.len() > 60_000 {
s.truncate(60_000);
s.push_str("\n…[output truncated]");
}
if s.trim().is_empty() {
s = format!("(exit {}, no output)", o.status.code().unwrap_or(-1));
}
(o.status.success(), s)
}
Err(e) => (false, format!("failed to launch: {e}")),
}
}
fn spawn_detached(program: &str, args: &[&str]) -> bool {
std::process::Command::new(program)
.args(args)
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.is_ok()
}
/// Lock the workstation (screen-lock). Works from the interactive user session.
pub fn lock_workstation() -> bool {
spawn_detached("rundll32.exe", &["user32.dll,LockWorkStation"])
}
/// Log the interactive user off.
pub fn logoff() -> bool {
spawn_detached("shutdown.exe", &["/l", "/f"])
}
/// Restart the machine now.
pub fn reboot() -> bool {
spawn_detached("shutdown.exe", &["/r", "/t", "0"])
}
/// Shut the machine down now.
pub fn shutdown() -> bool {
spawn_detached("shutdown.exe", &["/s", "/t", "0"])
}
/// Is this host process running with an elevated (Administrator) token? Reported
/// to the viewer so the UI can show whether `run` commands have admin rights.
pub fn is_elevated() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{
GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
unsafe {
let mut token = HANDLE::default();
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() {
return false;
}
let mut elev = TOKEN_ELEVATION::default();
let mut ret = 0u32;
let ok = GetTokenInformation(
token,
TokenElevation,
Some(&mut elev as *mut _ as *mut c_void),
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
&mut ret,
)
.is_ok();
let _ = CloseHandle(token);
ok && elev.TokenIsElevated != 0
}
}
#[cfg(test)]
mod admin_tests {
use super::*;
// Safe: runs `whoami` only — never fires lock/logoff/reboot/shutdown.
#[test]
fn run_command_captures_output() {
let (ok, out) = run_command("whoami");
assert!(ok, "whoami should succeed: {out}");
assert!(!out.trim().is_empty(), "expected username output");
let _ = is_elevated(); // must not panic
eprintln!("whoami -> {} | elevated={}", out.trim(), is_elevated());
}
}
/// Send a Secure Attention Sequence (Ctrl+Alt+Del) to the active session. The
/// host must be elevated and the SoftwareSASGeneration policy must allow
/// software SAS (set by `--install-admin-task`); fails gracefully otherwise.
pub fn send_cad() -> bool {
use windows::Win32::Security::Authentication::Identity::SendSAS;
// AsUser = TRUE: generated as the interactive (elevated) user.
unsafe { SendSAS(windows::Win32::Foundation::TRUE) };
true
}
/// Make UAC consent prompts appear on the NORMAL desktop (so a remote viewer can
/// see and click them — "enter admin pass" over a session) instead of the
/// isolated secure desktop. `visible=false` restores the Windows default.
/// Writes HKLM, so it needs an elevated host; fails gracefully otherwise.
pub fn set_uac_visible(visible: bool) -> bool {
let val = if visible { "0" } else { "1" };
std::process::Command::new("reg")
.args([
"add",
r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System",
"/v", "PromptOnSecureDesktop", "/t", "REG_DWORD", "/d", val, "/f",
])
.creation_flags(CREATE_NO_WINDOW)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Approve-before-connect prompt: a topmost Yes/No box on the interactive
/// desktop ("<peer> (<user>) wants to control this PC"). Auto-dismisses after
/// 20s (returns false), matching the engine's own approve timeout, via
/// user32's MessageBoxTimeoutW (exported by name; stable since XP). Returns
/// true only on an explicit Yes.
pub fn approve_prompt(peer: &str, user: &str) -> bool {
#[link(name = "user32")]
extern "system" {
fn MessageBoxTimeoutW(
hwnd: isize,
lptext: *const u16,
lpcaption: *const u16,
utype: u32,
wlangid: u16,
dwmilliseconds: u32,
) -> i32;
}
let from = match (peer.is_empty(), user.is_empty()) {
(true, true) => "an unknown viewer".to_string(),
(false, true) => peer.to_string(),
(true, false) => user.to_string(),
(false, false) => format!("{peer} ({user})"),
};
let text = format!(
"Incoming NetDesk connection\n\nFrom: {from}\n\nThe viewer entered the correct password.\nAccept remote control of this PC?"
);
let wide = |s: &str| s.encode_utf16().chain(std::iter::once(0)).collect::<Vec<u16>>();
let t = wide(&text);
let c = wide("NetDesk — accept connection?");
const MB_YESNO: u32 = 0x0000_0004;
const MB_ICONQUESTION: u32 = 0x0000_0020;
const MB_SYSTEMMODAL: u32 = 0x0000_1000; // topmost, above fullscreen apps
const MB_SETFOREGROUND: u32 = 0x0001_0000;
const MB_DEFBUTTON2: u32 = 0x0000_0100; // default = No (Enter won't accept)
const IDYES: i32 = 6;
let r = unsafe {
MessageBoxTimeoutW(
0,
t.as_ptr(),
c.as_ptr(),
MB_YESNO | MB_ICONQUESTION | MB_SYSTEMMODAL | MB_SETFOREGROUND | MB_DEFBUTTON2,
0,
20_000,
)
};
r == IDYES
}
+118
View File
@@ -0,0 +1,118 @@
//! Lightweight host metrics (CPU %, RAM, disk, battery, uptime) for the portal's
//! live monitoring. Windows-only; sampled every few seconds by the engine and
//! pushed to the server as a `host.telemetry` control message.
#![cfg(windows)]
use serde_json::{json, Value};
/// Keeps the previous CPU time sample so each `sample()` yields a real % over
/// the interval since the last call.
pub struct Telemetry {
prev_idle: u64,
prev_total: u64,
}
impl Telemetry {
pub fn new() -> Self {
Self { prev_idle: 0, prev_total: 0 }
}
pub fn sample(&mut self) -> Value {
let cpu = self.cpu_percent();
let (ram_used, ram_total) = mem();
let (disk_used, disk_total) = disk();
let (battery, charging) = power();
json!({
"t": "host.telemetry",
"cpu": cpu,
"ram_used": ram_used, "ram_total": ram_total,
"disk_used": disk_used, "disk_total": disk_total,
"battery": battery, "charging": charging,
"uptime": uptime_secs(),
})
}
fn cpu_percent(&mut self) -> u32 {
use windows::Win32::Foundation::FILETIME;
use windows::Win32::System::Threading::GetSystemTimes;
unsafe {
let mut idle = FILETIME::default();
let mut kern = FILETIME::default();
let mut user = FILETIME::default();
if GetSystemTimes(Some(&mut idle), Some(&mut kern), Some(&mut user)).is_err() {
return 0;
}
let ft = |f: FILETIME| ((f.dwHighDateTime as u64) << 32) | f.dwLowDateTime as u64;
let idle_t = ft(idle);
// Kernel time INCLUDES idle, so kernel+user is the full busy+idle total.
let total_t = ft(kern) + ft(user);
let d_total = total_t.saturating_sub(self.prev_total);
let d_idle = idle_t.saturating_sub(self.prev_idle);
self.prev_total = total_t;
self.prev_idle = idle_t;
if d_total == 0 {
return 0;
}
let busy = d_total.saturating_sub(d_idle);
((busy as f64 / d_total as f64) * 100.0).round().clamp(0.0, 100.0) as u32
}
}
}
fn mem() -> (u64, u64) {
use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
unsafe {
let mut m = MEMORYSTATUSEX {
dwLength: std::mem::size_of::<MEMORYSTATUSEX>() as u32,
..Default::default()
};
if GlobalMemoryStatusEx(&mut m).is_ok() {
(m.ullTotalPhys.saturating_sub(m.ullAvailPhys), m.ullTotalPhys)
} else {
(0, 0)
}
}
}
fn disk() -> (u64, u64) {
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
unsafe {
let path: Vec<u16> = "C:\\".encode_utf16().chain(std::iter::once(0)).collect();
let mut free_avail = 0u64;
let mut total = 0u64;
let mut total_free = 0u64;
if GetDiskFreeSpaceExW(
PCWSTR(path.as_ptr()),
Some(&mut free_avail),
Some(&mut total),
Some(&mut total_free),
)
.is_ok()
{
(total.saturating_sub(total_free), total)
} else {
(0, 0)
}
}
}
fn power() -> (i32, bool) {
use windows::Win32::System::Power::GetSystemPowerStatus;
use windows::Win32::System::Power::SYSTEM_POWER_STATUS;
unsafe {
let mut s = SYSTEM_POWER_STATUS::default();
if GetSystemPowerStatus(&mut s).is_ok() {
let pct = if s.BatteryLifePercent == 255 { -1 } else { s.BatteryLifePercent as i32 };
let charging = s.ACLineStatus == 1; // 1 = plugged in
(pct, charging)
} else {
(-1, true)
}
}
}
fn uptime_secs() -> u64 {
use windows::Win32::System::SystemInformation::GetTickCount64;
unsafe { GetTickCount64() / 1000 }
}
+209
View File
@@ -0,0 +1,209 @@
//! Interactive terminal via Windows ConPTY (pseudo-console) — a real shell the
//! viewer drives, not the one-shot `run` command. Mirrors RustDesk 1.5's
//! terminal: `term.open` spawns cmd.exe attached to a pseudo-console, its output
//! streams back as `term.data`, the viewer's keystrokes are written to its
//! input, `term.resize` resizes it, and `term.close` / shell exit tears it down.
#![cfg(windows)]
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use windows::core::PWSTR;
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Storage::FileSystem::{ReadFile, WriteFile};
use windows::Win32::System::Console::{
ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON,
};
use windows::Win32::System::Pipes::CreatePipe;
use windows::Win32::System::Threading::{
CreateProcessW, DeleteProcThreadAttributeList, InitializeProcThreadAttributeList,
TerminateProcess, UpdateProcThreadAttribute, EXTENDED_STARTUPINFO_PRESENT, PROCESS_INFORMATION,
STARTUPINFOEXW, STARTUPINFOW,
};
// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 (not a named const in the
// windows crate's Threading module); value from the Windows SDK.
const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x0002_0016;
/// A running ConPTY terminal: write to its input, resize, or kill it. The
/// output is pumped by a reader thread (see [`Terminal::spawn`]).
pub struct Terminal {
hpc: HPCON,
input_write: HANDLE,
proc: HANDLE,
thread: HANDLE,
stop: Arc<AtomicBool>,
attr_list: Vec<u8>, // keep the proc-thread attribute buffer alive til drop
}
// The handles are owned by this struct + used from the engine thread; the reader
// thread only touches a cloned output handle. Mark Send so run.rs can hold it in
// its HashMap across .await points (the engine loop is single-threaded per host).
unsafe impl Send for Terminal {}
impl Terminal {
/// Spawn `cmd.exe` on a fresh pseudo-console sized `cols`x`rows`. `on_output`
/// is called (on a reader thread) with each chunk of raw terminal bytes
/// (VT-encoded); `on_exit` is called once when the reader sees EOF. Returns
/// the live terminal handle.
pub fn spawn(
cols: u32,
rows: u32,
on_output: impl Fn(Vec<u8>) + Send + 'static,
on_exit: impl FnOnce() + Send + 'static,
) -> std::io::Result<Terminal> {
unsafe {
// Two pipes: viewer->pty input, pty->viewer output.
let mut in_r = HANDLE::default();
let mut in_w = HANDLE::default();
let mut out_r = HANDLE::default();
let mut out_w = HANDLE::default();
CreatePipe(&mut in_r, &mut in_w, None, 0)
.map_err(|e| std::io::Error::other(format!("CreatePipe(in): {e}")))?;
CreatePipe(&mut out_r, &mut out_w, None, 0)
.map_err(|e| std::io::Error::other(format!("CreatePipe(out): {e}")))?;
let size = COORD {
X: cols.clamp(1, 1000) as i16,
Y: rows.clamp(1, 1000) as i16,
};
let hpc: HPCON = CreatePseudoConsole(size, in_r, out_w, 0)
.map_err(|e| std::io::Error::other(format!("CreatePseudoConsole: {e}")))?;
// The pseudo-console now owns these ends; we keep in_w + out_r.
let _ = CloseHandle(in_r);
let _ = CloseHandle(out_w);
// Build STARTUPINFOEX with the PSEUDOCONSOLE attribute.
let mut attr_size: usize = 0;
let _ = InitializeProcThreadAttributeList(
windows::Win32::System::Threading::LPPROC_THREAD_ATTRIBUTE_LIST(std::ptr::null_mut()),
1,
0,
&mut attr_size,
);
let mut attr_list = vec![0u8; attr_size];
let attr =
windows::Win32::System::Threading::LPPROC_THREAD_ATTRIBUTE_LIST(attr_list.as_mut_ptr() as *mut c_void);
InitializeProcThreadAttributeList(attr, 1, 0, &mut attr_size)
.map_err(|e| std::io::Error::other(format!("InitAttrList: {e}")))?;
UpdateProcThreadAttribute(
attr,
0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
// Per the Win32 ConPTY sample (EchoCon), lpValue is the HPCON
// handle value passed as the pointer, cbValue = sizeof(HPCON).
Some(hpc.0 as *const c_void),
std::mem::size_of::<HPCON>(),
None,
None,
)
.map_err(|e| std::io::Error::other(format!("UpdateAttr: {e}")))?;
let mut si = STARTUPINFOEXW {
StartupInfo: STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOEXW>() as u32,
..Default::default()
},
lpAttributeList: attr,
};
let mut pi = PROCESS_INFORMATION::default();
// Mutable, NUL-terminated command line for CreateProcessW.
let mut cmd: Vec<u16> = "cmd.exe".encode_utf16().chain(std::iter::once(0)).collect();
let created = CreateProcessW(
None,
PWSTR(cmd.as_mut_ptr()),
None,
None,
false,
EXTENDED_STARTUPINFO_PRESENT,
None,
None,
&si.StartupInfo,
&mut pi,
);
// DeleteProcThreadAttributeList uses lpAttributeList; keep attr_list alive.
let _ = &mut si;
if let Err(e) = created {
DeleteProcThreadAttributeList(attr);
let _ = CloseHandle(in_w);
let _ = CloseHandle(out_r);
ClosePseudoConsole(hpc);
return Err(std::io::Error::other(format!("CreateProcessW(cmd): {e}")));
}
// Reader thread: pump ConPTY output -> on_output, EOF -> on_exit.
let stop = Arc::new(AtomicBool::new(false));
let r_stop = stop.clone();
let out_r_raw = out_r.0 as isize;
std::thread::Builder::new()
.name("netdesk-term-read".into())
.spawn(move || {
let out_r = HANDLE(out_r_raw as *mut c_void);
let mut buf = [0u8; 8192];
loop {
if r_stop.load(Ordering::Relaxed) {
break;
}
let mut read = 0u32;
let ok = unsafe { ReadFile(out_r, Some(&mut buf), Some(&mut read), None) };
if ok.is_err() || read == 0 {
break; // pipe closed / shell exited
}
on_output(buf[..read as usize].to_vec());
}
unsafe {
let _ = CloseHandle(out_r);
}
on_exit();
})
.ok();
Ok(Terminal {
hpc,
input_write: in_w,
proc: pi.hProcess,
thread: pi.hThread,
stop,
attr_list,
})
}
}
/// Write viewer keystrokes / command bytes to the shell.
pub fn write_input(&self, data: &[u8]) {
unsafe {
let mut written = 0u32;
let _ = WriteFile(self.input_write, Some(data), Some(&mut written), None);
}
}
/// Resize the pseudo-console.
pub fn resize(&self, cols: u32, rows: u32) {
unsafe {
let _ = ResizePseudoConsole(
self.hpc,
COORD {
X: cols.clamp(1, 1000) as i16,
Y: rows.clamp(1, 1000) as i16,
},
);
}
}
}
impl Drop for Terminal {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
unsafe {
// Closing the PTY + input signals the shell to exit; then reap it.
ClosePseudoConsole(self.hpc);
let _ = CloseHandle(self.input_write);
let _ = TerminateProcess(self.proc, 0);
let _ = CloseHandle(self.proc);
let _ = CloseHandle(self.thread);
}
let _ = &self.attr_list; // kept alive until here
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Wake-on-LAN magic-packet sender. An ONLINE NetDesk host can wake another PC
//! on its LAN on command (sys action `wol`), so a remote user whose own PC is
//! asleep can wake it through any online peer on the same network — the server
//! routes the `wol` command to a same-LAN peer, which calls [`send_wol`].
//!
//! Driverless + dependency-free: a standard WoL magic packet is 6×0xFF followed
//! by the target MAC repeated 16 times (102 bytes), UDP-broadcast to the LAN.
//! Developed by Akronlogic.
use std::net::UdpSocket;
/// Parse "AA:BB:CC:DD:EE:FF" or "AA-BB-..." into 6 bytes.
pub fn parse_mac(s: &str) -> Option<[u8; 6]> {
let parts: Vec<&str> = s.split(|c| c == ':' || c == '-').collect();
if parts.len() != 6 {
return None;
}
let mut mac = [0u8; 6];
for (i, p) in parts.iter().enumerate() {
mac[i] = u8::from_str_radix(p.trim(), 16).ok()?;
}
Some(mac)
}
/// Build the 102-byte WoL magic packet: 6×0xFF, then the MAC ×16.
pub fn magic_packet(mac: &[u8; 6]) -> [u8; 102] {
let mut pkt = [0u8; 102];
for b in pkt.iter_mut().take(6) {
*b = 0xFF;
}
for i in 0..16 {
pkt[6 + i * 6..6 + i * 6 + 6].copy_from_slice(mac);
}
pkt
}
/// Broadcast the magic packet for `mac_str` to the LAN. Sends to the limited
/// broadcast on the two conventional WoL ports (9 and 7). Returns how many of
/// the sends succeeded (0 = total failure).
pub fn send_wol(mac_str: &str) -> anyhow::Result<usize> {
let mac = parse_mac(mac_str).ok_or_else(|| anyhow::anyhow!("bad MAC: {mac_str}"))?;
let pkt = magic_packet(&mac);
let sock = UdpSocket::bind("0.0.0.0:0")?;
sock.set_broadcast(true)?;
let mut sent = 0usize;
for addr in ["255.255.255.255:9", "255.255.255.255:7"] {
if sock.send_to(&pkt, addr).is_ok() {
sent += 1;
}
}
if sent == 0 {
anyhow::bail!("no broadcast send succeeded");
}
Ok(sent)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_builds_magic_packet() {
assert_eq!(parse_mac("01:02:03:04:05:06").unwrap(), [1, 2, 3, 4, 5, 6]);
assert_eq!(parse_mac("AA-BB-CC-DD-EE-FF").unwrap(), [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
assert!(parse_mac("nope").is_none());
let p = magic_packet(&[1, 2, 3, 4, 5, 6]);
assert_eq!(p.len(), 102);
assert_eq!(&p[0..6], &[0xFF; 6]);
assert_eq!(&p[6..12], &[1, 2, 3, 4, 5, 6]);
assert_eq!(&p[96..102], &[1, 2, 3, 4, 5, 6]); // 16th repeat
}
// Prove the packet is actually built + transmitted correctly by sending it
// to a bound loopback receiver and reading the 102 bytes back.
#[test]
fn sends_magic_packet_over_udp_loopback() {
let rx = UdpSocket::bind("127.0.0.1:0").unwrap();
let port = rx.local_addr().unwrap().port();
let mac = parse_mac("aa:bb:cc:dd:ee:ff").unwrap();
let pkt = magic_packet(&mac);
let tx = UdpSocket::bind("0.0.0.0:0").unwrap();
tx.send_to(&pkt, ("127.0.0.1", port)).unwrap();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
let mut buf = [0u8; 256];
let (n, _) = rx.recv_from(&mut buf).unwrap();
assert_eq!(n, 102);
assert_eq!(&buf[0..6], &[0xFF; 6]);
assert_eq!(&buf[6..12], &mac);
assert_eq!(&buf[96..102], &mac);
println!("WOL_SEND_OK 102-byte magic packet transmitted + verified over UDP");
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "netdesk-opus"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "Opus (libopus) encode/decode for NetDesk audio. Developed by Akronlogic."
links = "opus"
build = "build.rs"
[dependencies]
anyhow = { workspace = true }
[build-dependencies]
bindgen = "=0.65.1"
+48
View File
@@ -0,0 +1,48 @@
//! Link the vendored static libopus (UCRT MinGW ABI) + generate FFI bindings
//! from the vendored opus headers via bindgen (libclang). Same vendoring
//! approach as netdesk-vpx: a UCRT-ABI libopus.a so there is no vcpkg/MSYS2 or
//! runtime DLL dependency, and no fragile build-from-source (audiopus_sys's
//! build script mangles extended-length paths on this toolchain).
use std::path::PathBuf;
fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let tp = manifest.join("..").join("third_party").join("ucrt64");
let lib = tp.join("lib");
let inc = tp.join("include");
println!("cargo:rustc-link-search=native={}", lib.display());
println!("cargo:rustc-link-lib=static=opus");
println!("cargo:rerun-if-changed=wrapper.h");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed={}", lib.join("libopus.a").display());
if std::env::var("LIBCLANG_PATH").is_err() {
for p in ["C:/Program Files/LLVM/bin", "C:/Program Files (x86)/LLVM/bin"] {
if PathBuf::from(p).join("libclang.dll").exists() {
std::env::set_var("LIBCLANG_PATH", p);
break;
}
}
}
// OpusEncoder / OpusDecoder are opaque (used only through pointers), so
// bindgen's default opaque representation is exactly right here — unlike
// libvpx's config structs, we never set their fields.
let regex = "^(opus|Opus|OPUS).*";
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.clang_arg(format!("-I{}", inc.display()))
.allowlist_type(regex)
.allowlist_var(regex)
.allowlist_function(regex)
.rustified_enum(regex)
.trust_clang_mangling(false)
.layout_tests(false)
.generate_comments(false)
.generate()
.expect("bindgen libopus");
bindings
.write_to_file(PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("opus_ffi.rs"))
.expect("write opus_ffi.rs");
}
+225
View File
@@ -0,0 +1,225 @@
//! Opus audio encode/decode over the vendored static libopus (UCRT MinGW).
//!
//! The raw FFI is generated by bindgen from the vendored headers (build.rs).
//! `OpusEncoder`/`OpusDecoder` are opaque (used only through pointers), so
//! bindgen's default handling is correct — no hand-written structs needed,
//! unlike netdesk-vpx's config structs.
//!
//! NetDesk audio is 48 kHz stereo, one 20 ms frame (960 samples/channel) per
//! Opus packet, RESTRICTED_LOWDELAY — matching what the web viewer sends/decodes
//! and what the host system-audio capture produces.
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(dead_code)]
use anyhow::{bail, Result};
pub mod ffi {
#![allow(
non_camel_case_types,
non_snake_case,
non_upper_case_globals,
dead_code,
improper_ctypes
)]
include!(concat!(env!("OUT_DIR"), "/opus_ffi.rs"));
}
use std::ffi::CStr;
use std::os::raw::c_int;
/// 48 kHz — the Opus "fullband" rate NetDesk uses end to end.
pub const SAMPLE_RATE: u32 = 48_000;
/// 20 ms frame at 48 kHz = 960 samples per channel.
pub const FRAME_SIZE: usize = 960;
/// Opus can emit up to 120 ms per packet = 5760 samples/channel; size any decode
/// scratch for the max so a decode can never overflow.
pub const MAX_FRAME_SIZE: usize = 5760;
/// libopus version string, e.g. "libopus 1.5.2".
pub fn version() -> String {
unsafe {
let p = ffi::opus_get_version_string();
if p.is_null() {
return String::new();
}
CStr::from_ptr(p).to_string_lossy().into_owned()
}
}
/// Opus encoder for system audio: RESTRICTED_LOWDELAY (lowest algorithmic delay,
/// which is what a screen-share wants). Input PCM is interleaved i16.
pub struct Encoder {
enc: *mut ffi::OpusEncoder,
channels: usize,
}
impl Encoder {
pub fn new(sample_rate: u32, channels: u32) -> Result<Self> {
let mut err: c_int = 0;
let enc = unsafe {
ffi::opus_encoder_create(
sample_rate as i32,
channels as i32,
ffi::OPUS_APPLICATION_RESTRICTED_LOWDELAY as i32,
&mut err,
)
};
if enc.is_null() || err != 0 {
bail!("opus_encoder_create failed: {err}");
}
// Quality tuning for crystal-clear system/mic audio (defaults are
// conservative). 128 kbps VBR, max complexity, music-optimised — the
// audio stream is a tiny fraction of the video bitrate, so this costs
// almost nothing on the wire but is near-transparent for music/voice.
unsafe {
ffi::opus_encoder_ctl(enc, ffi::OPUS_SET_BITRATE_REQUEST as c_int, 128_000 as c_int);
ffi::opus_encoder_ctl(enc, ffi::OPUS_SET_VBR_REQUEST as c_int, 1 as c_int);
ffi::opus_encoder_ctl(enc, ffi::OPUS_SET_COMPLEXITY_REQUEST as c_int, 10 as c_int);
ffi::opus_encoder_ctl(
enc,
ffi::OPUS_SET_SIGNAL_REQUEST as c_int,
ffi::OPUS_SIGNAL_MUSIC as c_int,
);
}
Ok(Self {
enc,
channels: channels as usize,
})
}
/// Encode one frame of interleaved i16 PCM into a single Opus packet.
/// `frame_size` is samples PER CHANNEL (e.g. [`FRAME_SIZE`]).
pub fn encode(&mut self, pcm: &[i16], frame_size: usize) -> Result<Vec<u8>> {
if pcm.len() < frame_size * self.channels {
bail!(
"pcm too short: {} < {}",
pcm.len(),
frame_size * self.channels
);
}
let mut out = vec![0u8; 4000]; // max sensible Opus packet
let n = unsafe {
ffi::opus_encode(
self.enc,
pcm.as_ptr(),
frame_size as i32,
out.as_mut_ptr(),
out.len() as i32,
)
};
if n < 0 {
bail!("opus_encode failed: {n}");
}
out.truncate(n as usize);
Ok(out)
}
}
impl Drop for Encoder {
fn drop(&mut self) {
unsafe { ffi::opus_encoder_destroy(self.enc) };
}
}
/// Opus decoder. Output PCM is interleaved i16.
pub struct Decoder {
dec: *mut ffi::OpusDecoder,
channels: usize,
}
impl Decoder {
pub fn new(sample_rate: u32, channels: u32) -> Result<Self> {
let mut err: c_int = 0;
let dec =
unsafe { ffi::opus_decoder_create(sample_rate as i32, channels as i32, &mut err) };
if dec.is_null() || err != 0 {
bail!("opus_decoder_create failed: {err}");
}
Ok(Self {
dec,
channels: channels as usize,
})
}
/// Decode one Opus packet into interleaved i16 PCM. Returns
/// `samples_per_channel * channels` samples.
pub fn decode(&mut self, data: &[u8]) -> Result<Vec<i16>> {
let mut pcm = vec![0i16; MAX_FRAME_SIZE * self.channels];
let n = unsafe {
ffi::opus_decode(
self.dec,
data.as_ptr(),
data.len() as i32,
pcm.as_mut_ptr(),
MAX_FRAME_SIZE as i32,
0,
)
};
if n < 0 {
bail!("opus_decode failed: {n}");
}
pcm.truncate(n as usize * self.channels);
Ok(pcm)
}
}
impl Drop for Decoder {
fn drop(&mut self) {
unsafe { ffi::opus_decoder_destroy(self.dec) };
}
}
// Opus contexts hold raw pointers; they are not shared (no Sync) but a single
// owner may move them between threads (the host capture thread owns the Encoder,
// the viewer audio thread owns the Decoder).
unsafe impl Send for Encoder {}
unsafe impl Send for Decoder {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn links_libopus() {
let v = version();
assert!(v.to_lowercase().contains("opus"), "opus version: {v}");
println!("OPUS_LINK_OK {v}");
}
#[test]
fn opus_roundtrip_48k_stereo() {
let mut enc = Encoder::new(SAMPLE_RATE, 2).unwrap();
let mut dec = Decoder::new(SAMPLE_RATE, 2).unwrap();
// 5 frames of a full-scale 440 Hz stereo sine.
let mut max_abs = 0i32;
let mut total_out = 0usize;
let mut packet_bytes = 0usize;
for frame in 0..5 {
let mut pcm = vec![0i16; FRAME_SIZE * 2];
for i in 0..FRAME_SIZE {
let t = (frame * FRAME_SIZE + i) as f32 / SAMPLE_RATE as f32;
let s = (2.0 * std::f32::consts::PI * 440.0 * t).sin();
let v = (s * 30000.0) as i16;
pcm[i * 2] = v;
pcm[i * 2 + 1] = v;
}
let pkt = enc.encode(&pcm, FRAME_SIZE).unwrap();
assert!(!pkt.is_empty(), "empty opus packet");
packet_bytes += pkt.len();
let out = dec.decode(&pkt).unwrap();
assert_eq!(out.len(), FRAME_SIZE * 2, "decoded frame size (stereo)");
total_out += out.len();
for &s in &out {
max_abs = max_abs.max((s as i32).abs());
}
}
assert_eq!(total_out, 5 * FRAME_SIZE * 2);
// After the encoder's lookahead delay, the sine energy must come through
// (proves the codec actually ran, not just returned silence).
assert!(max_abs > 1000, "decoded audio is silent (max_abs={max_abs})");
println!("OPUS_ROUNDTRIP_OK 48k/stereo 5x20ms total_pkt_bytes={packet_bytes} peak={max_abs}");
}
}
+1
View File
@@ -0,0 +1 @@
#include <opus/opus.h>
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff. Show more