NetDesk 1.5.0 — source release
This commit is contained in:
commit
37aa876908
227 files changed
+80694
No files matched your search
+26
@@ -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
|
||||||
@@ -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 .
|
||||||
@@ -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'],
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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).
|
||||||
@@ -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:
|
||||||
@@ -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 "$@"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -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 & 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 & 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>
|
||||||
@@ -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()
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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])
|
||||||
@@ -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++",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
Generated
+5758
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
@@ -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 |
@@ -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 |
@@ -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",
|
||||||
|
] }
|
||||||
@@ -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
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
@@ -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) |
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in new issue
Block a user